diff --git a/common/answer.ts b/common/answer.ts new file mode 100644 index 00000000..36dd0415 --- /dev/null +++ b/common/answer.ts @@ -0,0 +1,31 @@ +import { User } from './user' + +export type Answer = { + id: string + number: number + contractId: string + createdTime: number + + userId: string + username: string + name: string + avatarUrl?: string + + text: string +} + +export const getNoneAnswer = (contractId: string, creator: User) => { + const { username, name, avatarUrl } = creator + + return { + id: '0', + number: 0, + contractId, + createdTime: Date.now(), + userId: creator.id, + username, + name, + avatarUrl, + text: 'None', + } +} diff --git a/common/antes.ts b/common/antes.ts index b8fa704c..d36ef584 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -61,3 +61,30 @@ export function getAnteBets( return { yesBet, noBet } } + +export function getFreeAnswerAnte( + creator: User, + contract: Contract, + anteBetId: string +) { + const { totalBets, totalShares } = contract + const amount = totalBets['0'] + const shares = totalShares['0'] + + const { createdTime } = contract + + const anteBet: Bet = { + id: anteBetId, + userId: creator.id, + contractId: contract.id, + amount, + shares, + outcome: '0', + probBefore: 0, + probAfter: 1, + createdTime, + isAnte: true, + } + + return anteBet +} diff --git a/common/bet.ts b/common/bet.ts index a875102c..7da4b18c 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,7 +4,7 @@ export type Bet = { contractId: string amount: number // bet size; negative if SELL bet - outcome: 'YES' | 'NO' + outcome: string shares: number // dynamic parimutuel pool weight; negative if SELL bet probBefore: number diff --git a/common/calculate.ts b/common/calculate.ts index 831b37de..3dbb6daf 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,69 +1,72 @@ +import * as _ from 'lodash' import { Bet } from './bet' import { Contract } from './contract' import { FEES } from './fees' -export function getProbability(totalShares: { YES: number; NO: number }) { - const { YES: y, NO: n } = totalShares - return y ** 2 / (y ** 2 + n ** 2) +export function getProbability(totalShares: { [outcome: string]: number }) { + // For binary contracts only. + return getOutcomeProbability(totalShares, 'YES') +} + +export function getOutcomeProbability( + totalShares: { + [outcome: string]: number + }, + outcome: string +) { + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + const shares = totalShares[outcome] ?? 0 + return shares ** 2 / squareSum } export function getProbabilityAfterBet( - totalShares: { YES: number; NO: number }, - outcome: 'YES' | 'NO', + totalShares: { + [outcome: string]: number + }, + outcome: string, bet: number ) { const shares = calculateShares(totalShares, bet, outcome) - const [YES, NO] = - outcome === 'YES' - ? [totalShares.YES + shares, totalShares.NO] - : [totalShares.YES, totalShares.NO + shares] + const prevShares = totalShares[outcome] ?? 0 + const newTotalShares = { ...totalShares, [outcome]: prevShares + shares } - return getProbability({ YES, NO }) + return getOutcomeProbability(newTotalShares, outcome) } export function calculateShares( - totalShares: { YES: number; NO: number }, + totalShares: { + [outcome: string]: number + }, bet: number, - betChoice: 'YES' | 'NO' + betChoice: string ) { - const [yesShares, noShares] = [totalShares.YES, totalShares.NO] + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + const shares = totalShares[betChoice] ?? 0 - const c = 2 * bet * Math.sqrt(yesShares ** 2 + noShares ** 2) + const c = 2 * bet * Math.sqrt(squareSum) - return betChoice === 'YES' - ? Math.sqrt(bet ** 2 + yesShares ** 2 + c) - yesShares - : Math.sqrt(bet ** 2 + noShares ** 2 + c) - noShares -} - -export function calculateEstimatedWinnings( - totalShares: { YES: number; NO: number }, - shares: number, - betChoice: 'YES' | 'NO' -) { - const ind = betChoice === 'YES' ? 1 : 0 - - const yesShares = totalShares.YES + ind * shares - const noShares = totalShares.NO + (1 - ind) * shares - - const estPool = Math.sqrt(yesShares ** 2 + noShares ** 2) - const total = ind * yesShares + (1 - ind) * noShares - - return ((1 - FEES) * (shares * estPool)) / total + return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares } export function calculateRawShareValue( - totalShares: { YES: number; NO: number }, + totalShares: { + [outcome: string]: number + }, shares: number, - betChoice: 'YES' | 'NO' + betChoice: string ) { - const [yesShares, noShares] = [totalShares.YES, totalShares.NO] - const currentValue = Math.sqrt(yesShares ** 2 + noShares ** 2) + const currentValue = Math.sqrt( + _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + ) - const postSaleValue = - betChoice === 'YES' - ? Math.sqrt(Math.max(0, yesShares - shares) ** 2 + noShares ** 2) - : Math.sqrt(yesShares ** 2 + Math.max(0, noShares - shares) ** 2) + const postSaleValue = Math.sqrt( + _.sumBy(Object.keys(totalShares), (outcome) => + outcome === betChoice + ? Math.max(0, totalShares[outcome] - shares) ** 2 + : totalShares[outcome] ** 2 + ) + ) return currentValue - postSaleValue } @@ -73,17 +76,22 @@ export function calculateMoneyRatio( bet: Bet, shareValue: number ) { - const { totalShares, pool } = contract + const { totalShares, totalBets, pool } = contract + const { outcome, amount } = bet - const p = getProbability(totalShares) + const p = getOutcomeProbability(totalShares, outcome) - const actual = pool.YES + pool.NO - shareValue + const actual = _.sum(Object.values(pool)) - shareValue - const betAmount = - bet.outcome === 'YES' ? p * bet.amount : (1 - p) * bet.amount + const betAmount = p * amount const expected = - p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO - betAmount + _.sumBy( + Object.keys(totalBets), + (outcome) => + getOutcomeProbability(totalShares, outcome) * + (totalBets as { [outcome: string]: number })[outcome] + ) - betAmount if (actual <= 0 || expected <= 0) return 0 @@ -91,14 +99,13 @@ export function calculateMoneyRatio( } export function calculateShareValue(contract: Contract, bet: Bet) { - const shareValue = calculateRawShareValue( - contract.totalShares, - bet.shares, - bet.outcome - ) + const { pool, totalShares } = contract + const { shares, outcome } = bet + + const shareValue = calculateRawShareValue(totalShares, shares, outcome) const f = calculateMoneyRatio(contract, bet, shareValue) - const myPool = contract.pool[bet.outcome] + const myPool = pool[outcome] const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool) return adjShareValue } @@ -109,11 +116,7 @@ export function calculateSaleAmount(contract: Contract, bet: Bet) { return deductFees(amount, winnings) } -export function calculatePayout( - contract: Contract, - bet: Bet, - outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' -) { +export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { if (outcome === 'CANCEL') return calculateCancelPayout(contract, bet) if (outcome === 'MKT') return calculateMktPayout(contract, bet) @@ -121,67 +124,100 @@ export function calculatePayout( } export function calculateCancelPayout(contract: Contract, bet: Bet) { - const totalBets = contract.totalBets.YES + contract.totalBets.NO - const pool = contract.pool.YES + contract.pool.NO + const { totalBets, pool } = contract + const betTotal = _.sum(Object.values(totalBets)) + const poolTotal = _.sum(Object.values(pool)) - return (bet.amount / totalBets) * pool + return (bet.amount / betTotal) * poolTotal } export function calculateStandardPayout( contract: Contract, bet: Bet, - outcome: 'YES' | 'NO' + outcome: string ) { const { amount, outcome: betOutcome, shares } = bet if (betOutcome !== outcome) return 0 - const { totalShares, phantomShares } = contract - if (totalShares[outcome] === 0) return 0 + const { totalShares, phantomShares, pool } = contract + if (!totalShares[outcome]) return 0 - const pool = contract.pool.YES + contract.pool.NO - const total = totalShares[outcome] - phantomShares[outcome] + const poolTotal = _.sum(Object.values(pool)) - const winnings = (shares / total) * pool + const total = + totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0) + + const winnings = (shares / total) * poolTotal // profit can be negative if using phantom shares return amount + (1 - FEES) * Math.max(0, winnings - amount) } export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { const { totalShares, pool, totalBets } = contract + const { shares, amount, outcome } = bet - const ind = bet.outcome === 'YES' ? 1 : 0 - const { shares, amount } = bet + const prevShares = totalShares[outcome] ?? 0 + const prevPool = pool[outcome] ?? 0 + const prevTotalBet = totalBets[outcome] ?? 0 const newContract = { ...contract, totalShares: { - YES: totalShares.YES + ind * shares, - NO: totalShares.NO + (1 - ind) * shares, + ...totalShares, + [outcome]: prevShares + shares, }, pool: { - YES: pool.YES + ind * amount, - NO: pool.NO + (1 - ind) * amount, + ...pool, + [outcome]: prevPool + amount, }, totalBets: { - YES: totalBets.YES + ind * amount, - NO: totalBets.NO + (1 - ind) * amount, + ...totalBets, + [outcome]: prevTotalBet + amount, }, } - return calculateStandardPayout(newContract, bet, bet.outcome) + return calculateStandardPayout(newContract, bet, outcome) } function calculateMktPayout(contract: Contract, bet: Bet) { + if (contract.outcomeType === 'BINARY') + return calculateBinaryMktPayout(contract, bet) + + const { totalShares, pool } = contract + + const totalPool = _.sum(Object.values(pool)) + const sharesSquareSum = _.sumBy( + Object.values(totalShares), + (shares) => shares ** 2 + ) + + const weightedShareTotal = _.sumBy(Object.keys(totalShares), (outcome) => { + // Avoid O(n^2) by reusing sharesSquareSum for prob. + const shares = totalShares[outcome] + const prob = shares ** 2 / sharesSquareSum + return prob * shares + }) + + const { outcome, amount, shares } = bet + + const betP = getOutcomeProbability(totalShares, outcome) + const winnings = ((betP * shares) / weightedShareTotal) * totalPool + + return deductFees(amount, winnings) +} + +function calculateBinaryMktPayout(contract: Contract, bet: Bet) { + const { resolutionProbability, totalShares, phantomShares } = contract const p = - contract.resolutionProbability !== undefined - ? contract.resolutionProbability - : getProbability(contract.totalShares) + resolutionProbability !== undefined + ? resolutionProbability + : getProbability(totalShares) const pool = contract.pool.YES + contract.pool.NO const weightedShareTotal = - p * (contract.totalShares.YES - contract.phantomShares.YES) + - (1 - p) * (contract.totalShares.NO - contract.phantomShares.NO) + p * (totalShares.YES - (phantomShares?.YES ?? 0)) + + (1 - p) * (totalShares.NO - (phantomShares?.NO ?? 0)) const { outcome, amount, shares } = bet @@ -197,15 +233,6 @@ export function resolvedPayout(contract: Contract, bet: Bet) { throw new Error('Contract was not resolved') } -// deprecated use MKT payout -export function currentValue(contract: Contract, bet: Bet) { - const prob = getProbability(contract.pool) - const yesPayout = calculatePayout(contract, bet, 'YES') - const noPayout = calculatePayout(contract, bet, 'NO') - - return prob * yesPayout + (1 - prob) * noPayout -} - export const deductFees = (betAmount: number, winnings: number) => { return winnings > betAmount ? betAmount + (1 - FEES) * (winnings - betAmount) diff --git a/common/contract.ts b/common/contract.ts index f5733059..57a8d0b7 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,3 +1,5 @@ +import { Answer } from './answer' + export type Contract = { id: string slug: string // auto-generated; must be unique @@ -11,14 +13,17 @@ export type Contract = { description: string // More info about what the contract is about tags: string[] lowercaseTags: string[] - outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date' visibility: 'public' | 'unlisted' + outcomeType: 'BINARY' | 'MULTI' | 'FREE_RESPONSE' + multiOutcomes?: string[] // Used for outcomeType 'MULTI'. + answers?: Answer[] // Used for outcomeType 'FREE_RESPONSE'. + mechanism: 'dpm-2' - phantomShares: { YES: number; NO: number } - pool: { YES: number; NO: number } - totalShares: { YES: number; NO: number } - totalBets: { YES: number; NO: number } + phantomShares?: { [outcome: string]: number } + pool: { [outcome: string]: number } + totalShares: { [outcome: string]: number } + totalBets: { [outcome: string]: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed @@ -26,7 +31,7 @@ export type Contract = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market - resolution?: outcome // Chosen by creator; must be one of outcomes + resolution?: string resolutionProbability?: number closeEmailsSent?: number @@ -34,4 +39,4 @@ export type Contract = { volume7Days: number } -export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT' +export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' diff --git a/common/new-bet.ts b/common/new-bet.ts index 61c72015..29fd421a 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,9 +1,13 @@ import { Bet } from './bet' -import { calculateShares, getProbability } from './calculate' +import { + calculateShares, + getProbability, + getOutcomeProbability, +} from './calculate' import { Contract } from './contract' import { User } from './user' -export const getNewBetInfo = ( +export const getNewBinaryBetInfo = ( user: User, outcome: 'YES' | 'NO', amount: number, @@ -52,3 +56,43 @@ export const getNewBetInfo = ( return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } + +export const getNewMultiBetInfo = ( + user: User, + outcome: string, + amount: number, + contract: Contract, + newBetId: string +) => { + const { pool, totalShares, totalBets } = contract + + const prevOutcomePool = pool[outcome] ?? 0 + const newPool = { ...pool, [outcome]: prevOutcomePool + amount } + + const shares = calculateShares(contract.totalShares, amount, outcome) + + const prevShares = totalShares[outcome] ?? 0 + const newTotalShares = { ...totalShares, [outcome]: prevShares + shares } + + const prevTotalBets = totalBets[outcome] ?? 0 + const newTotalBets = { ...totalBets, [outcome]: prevTotalBets + amount } + + const probBefore = getOutcomeProbability(totalShares, outcome) + const probAfter = getOutcomeProbability(newTotalShares, outcome) + + const newBet: Bet = { + id: newBetId, + userId: user.id, + contractId: contract.id, + amount, + shares, + outcome, + probBefore, + probAfter, + createdTime: Date.now(), + } + + const newBalance = user.balance - amount + + return { newBet, newPool, newTotalShares, newTotalBets, newBalance } +} diff --git a/common/new-contract.ts b/common/new-contract.ts index 99f27874..cc742f2f 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -1,32 +1,37 @@ import { calcStartPool } from './antes' - -import { Contract } from './contract' +import { Contract, outcomeType } from './contract' import { User } from './user' import { parseTags } from './util/parse' +import { removeUndefinedProps } from './util/object' export function getNewContract( id: string, slug: string, creator: User, question: string, + outcomeType: outcomeType, description: string, initialProb: number, ante: number, closeTime: number, extraTags: string[] ) { - const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } = - calcStartPool(initialProb, ante) - const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - const contract: Contract = { + const propsByOutcomeType = + outcomeType === 'BINARY' + ? getBinaryProps(initialProb, ante) + : getFreeAnswerProps(ante) + + const contract: Contract = removeUndefinedProps({ id, slug, - outcomeType: 'BINARY', + mechanism: 'dpm-2', + outcomeType, + ...propsByOutcomeType, creatorId: creator.id, creatorName: creator.name, @@ -38,22 +43,43 @@ export function getNewContract( tags, lowercaseTags, visibility: 'public', + isResolved: false, + createdTime: Date.now(), + lastUpdatedTime: Date.now(), + closeTime, - mechanism: 'dpm-2', + volume24Hours: 0, + volume7Days: 0, + }) + + return contract +} + +const getBinaryProps = (initialProb: number, ante: number) => { + const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } = + calcStartPool(initialProb, ante) + + return { phantomShares: { YES: phantomYes, NO: phantomNo }, pool: { YES: poolYes, NO: poolNo }, totalShares: { YES: sharesYes, NO: sharesNo }, totalBets: { YES: poolYes, NO: poolNo }, - isResolved: false, - - createdTime: Date.now(), - lastUpdatedTime: Date.now(), - - volume24Hours: 0, - volume7Days: 0, } - - if (closeTime) contract.closeTime = closeTime - - return contract +} + +const getFreeAnswerProps = (ante: number) => { + return { + pool: { '0': ante }, + totalShares: { '0': ante }, + totalBets: { '0': ante }, + answers: [], + } +} + +const getMultiProps = ( + outcomes: string[], + initialProbs: number[], + ante: number +) => { + // Not implemented. } diff --git a/common/payouts.ts b/common/payouts.ts index a372f6bc..0b917943 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -2,12 +2,12 @@ import * as _ from 'lodash' import { Bet } from './bet' import { deductFees, getProbability } from './calculate' -import { Contract, outcome } from './contract' +import { Contract } from './contract' import { CREATOR_FEE, FEES } from './fees' export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { const { pool } = contract - const poolTotal = pool.YES + pool.NO + const poolTotal = _.sum(Object.values(pool)) console.log('resolved N/A, pool M$', poolTotal) const betSum = _.sumBy(bets, (b) => b.amount) @@ -19,18 +19,17 @@ export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { } export const getStandardPayouts = ( - outcome: 'YES' | 'NO', + outcome: string, contract: Contract, bets: Bet[] ) => { - const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') - const winningBets = outcome === 'YES' ? yesBets : noBets + const winningBets = bets.filter((bet) => bet.outcome === outcome) - const pool = contract.pool.YES + contract.pool.NO + const poolTotal = _.sum(Object.values(contract.pool)) const totalShares = _.sumBy(winningBets, (b) => b.shares) const payouts = winningBets.map(({ userId, amount, shares }) => { - const winnings = (shares / totalShares) * pool + const winnings = (shares / totalShares) * poolTotal const profit = winnings - amount // profit can be negative if using phantom shares @@ -45,7 +44,7 @@ export const getStandardPayouts = ( 'resolved', outcome, 'pool', - pool, + poolTotal, 'profits', profits, 'creator fee', @@ -101,7 +100,7 @@ export const getMktPayouts = ( } export const getPayouts = ( - outcome: outcome, + outcome: string, contract: Contract, bets: Bet[], resolutionProbability?: number @@ -114,5 +113,8 @@ export const getPayouts = ( return getMktPayouts(contract, bets, resolutionProbability) case 'CANCEL': return getCancelPayouts(contract, bets) + default: + // Multi outcome. + return getStandardPayouts(outcome, contract, bets) } } diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 88191742..cc824386 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -1,7 +1,7 @@ import { Bet } from './bet' import { calculateShareValue, deductFees, getProbability } from './calculate' import { Contract } from './contract' -import { CREATOR_FEE, FEES } from './fees' +import { CREATOR_FEE } from './fees' import { User } from './user' export const getSellBetInfo = ( @@ -10,30 +10,21 @@ export const getSellBetInfo = ( contract: Contract, newBetId: string ) => { + const { pool, totalShares, totalBets } = contract const { id: betId, amount, shares, outcome } = bet - const { YES: yesPool, NO: noPool } = contract.pool - const { YES: yesShares, NO: noShares } = contract.totalShares - const { YES: yesBets, NO: noBets } = contract.totalBets - const adjShareValue = calculateShareValue(contract, bet) - const newPool = - outcome === 'YES' - ? { YES: yesPool - adjShareValue, NO: noPool } - : { YES: yesPool, NO: noPool - adjShareValue } + const newPool = { ...pool, [outcome]: pool[outcome] - adjShareValue } - const newTotalShares = - outcome === 'YES' - ? { YES: yesShares - shares, NO: noShares } - : { YES: yesShares, NO: noShares - shares } + const newTotalShares = { + ...totalShares, + [outcome]: totalShares[outcome] - shares, + } - const newTotalBets = - outcome === 'YES' - ? { YES: yesBets - amount, NO: noBets } - : { YES: yesBets, NO: noBets - amount } + const newTotalBets = { ...totalBets, [outcome]: totalBets[outcome] - amount } - const probBefore = getProbability(contract.totalShares) + const probBefore = getProbability(totalShares) const probAfter = getProbability(newTotalShares) const profit = adjShareValue - amount diff --git a/common/util/object.ts b/common/util/object.ts new file mode 100644 index 00000000..4148b057 --- /dev/null +++ b/common/util/object.ts @@ -0,0 +1,9 @@ +export const removeUndefinedProps = (obj: T): T => { + let newObj: any = {} + + for (let key of Object.keys(obj)) { + if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] + } + + return newObj +} diff --git a/firestore.rules b/firestore.rules index 253d57f5..a626ce1f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -27,20 +27,16 @@ service cloud.firestore { allow delete: if resource.data.creatorId == request.auth.uid; } - match /contracts/{contractId}/bets/{betId} { - allow read; - } - match /{somePath=**}/bets/{betId} { allow read; } - match /contracts/{contractId}/comments/{commentId} { + match /{somePath=**}/comments/{commentId} { allow read; allow create: if request.auth != null; } - match /{somePath=**}/comments/{commentId} { + match /{somePath=**}/answers/{answerId} { allow read; } @@ -49,13 +45,9 @@ service cloud.firestore { allow update, delete: if request.auth.uid == resource.data.curatorId; } - match /folds/{foldId}/followers/{userId} { + match /{somePath=**}/followers/{userId} { allow read; allow write: if request.auth.uid == userId; } - - match /{somePath=**}/followers/{userId} { - allow read; - } } } \ No newline at end of file diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index de8a8d6a..ab15eb70 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -1,11 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { getUser, removeUndefinedProps } from './utils' +import { getUser } from './utils' import { Contract } from '../../common/contract' import { Comment } from '../../common/comment' import { User } from '../../common/user' import { cleanUsername } from '../../common/util/clean-username' +import { removeUndefinedProps } from '../../common/util/object' +import { Answer } from '../../common/answer' export const changeUserInfo = functions .runWith({ minInstances: 1 }) @@ -88,12 +90,23 @@ export const changeUser = async ( userAvatarUrl: update.avatarUrl, }) + const answerSnap = await transaction.get( + firestore + .collectionGroup('answers') + .where('username', '==', user.username) + ) + const answerUpdate: Partial = removeUndefinedProps(update) + await transaction.update(userRef, userUpdate) await Promise.all( commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate)) ) + await Promise.all( + answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate)) + ) + await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate)) }) } diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts new file mode 100644 index 00000000..bcb25b03 --- /dev/null +++ b/functions/src/create-answer.ts @@ -0,0 +1,111 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { getNewMultiBetInfo } from '../../common/new-bet' +import { Answer } from '../../common/answer' +import { getValues } from './utils' + +export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + contractId: string + amount: number + text: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId, amount, text } = data + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + if (!text || typeof text !== 'string' || text.length > 10000) + return { status: 'error', message: 'Invalid text' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${userId}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) + return { status: 'error', message: 'User not found' } + const user = userSnap.data() as User + + if (user.balance < amount) + return { status: 'error', message: 'Insufficient balance' } + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) + return { status: 'error', message: 'Invalid contract' } + const contract = contractSnap.data() as Contract + + if (contract.outcomeType !== 'FREE_RESPONSE') + return { + status: 'error', + message: 'Requires a free response contract', + } + + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + return { status: 'error', message: 'Trading is closed' } + + const [lastAnswer] = await getValues( + firestore + .collection(`contracts/${contractId}/answers`) + .orderBy('number', 'desc') + .limit(1) + ) + + if (!lastAnswer) + return { status: 'error', message: 'Could not fetch last answer' } + + const number = lastAnswer.number + 1 + const id = `${number}` + + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc(id) + + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user + + const answer: Answer = { + id, + number, + contractId, + createdTime: Date.now(), + userId: user.id, + username, + name, + avatarUrl, + text, + } + transaction.create(newAnswerDoc, answer) + + const newBetDoc = firestore + .collection(`contracts/${contractId}/bets`) + .doc() + + const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = + getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id) + + transaction.create(newBetDoc, { ...newBet, isAnte: true }) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + answers: [...(contract.answers ?? []), answer], + }) + transaction.update(userDoc, { balance: newBalance }) + + return { status: 'success', answerId, betId: newBetDoc.id } + }) + } +) + +const firestore = admin.firestore() diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 8ab38b88..bb78f134 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -2,11 +2,16 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { chargeUser, getUser } from './utils' -import { Contract } from '../../common/contract' +import { Contract, outcomeType } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { getNewContract } from '../../common/new-contract' -import { getAnteBets, MINIMUM_ANTE } from '../../common/antes' +import { + getAnteBets, + getFreeAnswerAnte, + MINIMUM_ANTE, +} from '../../common/antes' +import { getNoneAnswer } from '../../common/answer' export const createContract = functions .runWith({ minInstances: 1 }) @@ -14,6 +19,7 @@ export const createContract = functions async ( data: { question: string + outcomeType: outcomeType description: string initialProb: number ante: number @@ -30,10 +36,17 @@ export const createContract = functions const { question, description, initialProb, ante, closeTime, tags } = data - if (!question || !initialProb) - return { status: 'error', message: 'Missing contract attributes' } + if (!question) + return { status: 'error', message: 'Missing question field' } - if (initialProb < 1 || initialProb > 99) + let outcomeType = data.outcomeType ?? 'BINARY' + if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) + return { status: 'error', message: 'Invalid outcomeType' } + + if ( + outcomeType === 'BINARY' && + (!initialProb || initialProb < 1 || initialProb > 99) + ) return { status: 'error', message: 'Invalid initial probability' } if ( @@ -63,6 +76,7 @@ export const createContract = functions slug, creator, question, + outcomeType, description, initialProb, ante, @@ -75,22 +89,36 @@ export const createContract = functions await contractRef.create(contract) if (ante) { - const yesBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() + if (outcomeType === 'BINARY') { + const yesBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - const noBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() + const noBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - const { yesBet, noBet } = getAnteBets( - creator, - contract, - yesBetDoc.id, - noBetDoc.id - ) - await yesBetDoc.set(yesBet) - await noBetDoc.set(noBet) + const { yesBet, noBet } = getAnteBets( + creator, + contract, + yesBetDoc.id, + noBetDoc.id + ) + await yesBetDoc.set(yesBet) + await noBetDoc.set(noBet) + } else if (outcomeType === 'FREE_RESPONSE') { + const noneAnswerDoc = firestore + .collection(`contracts/${contract.id}/answers`) + .doc('0') + const noneAnswer = getNoneAnswer(contract.id, creator) + await noneAnswerDoc.set(noneAnswer) + + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + const anteBet = getFreeAnswerAnte(creator, contract, anteBetDoc.id) + await anteBetDoc.set(anteBet) + } } return { status: 'success', contract } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index ba20df1e..d1c61e3f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -17,12 +17,23 @@ type market_resolved_template = { url: string } +const toDisplayResolution = (outcome: string, prob: number) => { + const display = { + YES: 'YES', + NO: 'NO', + CANCEL: 'N/A', + MKT: formatPercent(prob), + }[outcome] + + return display === undefined ? `#${outcome}` : display +} + export const sendMarketResolutionEmail = async ( userId: string, payout: number, creator: User, contract: Contract, - resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT', + resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string, resolutionProbability?: number ) => { const privateUser = await getPrivateUser(userId) @@ -38,13 +49,7 @@ export const sendMarketResolutionEmail = async ( const prob = resolutionProbability ?? getProbability(contract.totalShares) - const toDisplayResolution = { - YES: 'YES', - NO: 'NO', - CANCEL: 'N/A', - MKT: formatPercent(prob), - } - const outcome = toDisplayResolution[resolution] + const outcome = toDisplayResolution(resolution, prob) const subject = `Resolved ${outcome}: ${contract.question}` diff --git a/functions/src/index.ts b/functions/src/index.ts index 202136c9..50c748af 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -11,6 +11,7 @@ export * from './sell-bet' export * from './create-contract' export * from './create-user' export * from './create-fold' +export * from './create-answer' export * from './on-fold-follow' export * from './on-fold-delete' export * from './unsubscribe' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index dee470d6..e37dc12c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { User } from '../../common/user' -import { getNewBetInfo } from '../../common/new-bet' +import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet' export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -22,7 +22,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return { status: 'error', message: 'Invalid amount' } - if (outcome !== 'YES' && outcome !== 'NO') + if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) return { status: 'error', message: 'Invalid outcome' } // run as transaction to prevent race conditions @@ -42,16 +42,32 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { closeTime } = contract + const { closeTime, outcomeType } = contract if (closeTime && Date.now() > closeTime) return { status: 'error', message: 'Trading is closed' } + if (outcomeType === 'FREE_RESPONSE') { + const answerSnap = await transaction.get( + contractDoc.collection('answers').doc(outcome) + ) + if (!answerSnap.exists) + return { status: 'error', message: 'Invalid contract' } + } + const newBetDoc = firestore .collection(`contracts/${contractId}/bets`) .doc() const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = - getNewBetInfo(user, outcome, amount, contract, newBetDoc.id) + outcomeType === 'BINARY' + ? getNewBinaryBetInfo( + user, + outcome as 'YES' | 'NO', + amount, + contract, + newBetDoc.id + ) + : getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id) transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 5f297595..d0b6d1da 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -25,10 +25,25 @@ export const resolveMarket = functions const { outcome, contractId, probabilityInt } = data - if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + return { status: 'error', message: 'Invalid contract' } + const contract = contractSnap.data() as Contract + const { creatorId, outcomeType } = contract + + if (outcomeType === 'BINARY') { + if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) + return { status: 'error', message: 'Invalid outcome' } + } else if (outcomeType === 'FREE_RESPONSE') { + if (outcome !== 'CANCEL' && isNaN(+outcome)) + return { status: 'error', message: 'Invalid outcome' } + } else { + return { status: 'error', message: 'Invalid contract outcomeType' } + } if ( + outcomeType === 'BINARY' && probabilityInt !== undefined && (probabilityInt < 0 || probabilityInt > 100 || @@ -36,19 +51,13 @@ export const resolveMarket = functions ) return { status: 'error', message: 'Invalid probability' } - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - - if (contract.creatorId !== userId) + if (creatorId !== userId) return { status: 'error', message: 'User not creator of contract' } if (contract.resolution) return { status: 'error', message: 'Contract already resolved' } - const creator = await getUser(contract.creatorId) + const creator = await getUser(creatorId) if (!creator) return { status: 'error', message: 'Creator not found' } const resolutionProbability = @@ -112,7 +121,7 @@ const sendResolutionEmails = async ( userPayouts: { [userId: string]: number }, creator: User, contract: Contract, - outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT', + outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string, resolutionProbability?: number ) => { const nonWinners = _.difference( diff --git a/functions/src/scripts/migrate-to-dpm-2.ts b/functions/src/scripts/migrate-to-dpm-2.ts index 8ceef137..80523a95 100644 --- a/functions/src/scripts/migrate-to-dpm-2.ts +++ b/functions/src/scripts/migrate-to-dpm-2.ts @@ -83,11 +83,15 @@ async function recalculateContract( let totalShares = { YES: Math.sqrt(p) * (phantomAnte + realAnte), NO: Math.sqrt(1 - p) * (phantomAnte + realAnte), + } as { [outcome: string]: number } + + let pool = { YES: p * realAnte, NO: (1 - p) * realAnte } as { + [outcome: string]: number } - let pool = { YES: p * realAnte, NO: (1 - p) * realAnte } - - let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } + let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } as { + [outcome: string]: number + } const betsRef = contractRef.collection('bets') diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 95881870..9f3777e8 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -77,13 +77,3 @@ export const chargeUser = (userId: string, charge: number) => { return updateUserBalance(userId, -charge) } - -export const removeUndefinedProps = (obj: T): T => { - let newObj: any = {} - - for (let key of Object.keys(obj)) { - if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] - } - - return newObj -} diff --git a/web/components/answers-panel.tsx b/web/components/answers-panel.tsx new file mode 100644 index 00000000..483190ef --- /dev/null +++ b/web/components/answers-panel.tsx @@ -0,0 +1,546 @@ +import clsx from 'clsx' +import _ from 'lodash' +import { useEffect, useRef, useState } from 'react' +import Textarea from 'react-expanding-textarea' +import { XIcon } from '@heroicons/react/solid' + +import { Answer } from '../../common/answer' +import { Contract } from '../../common/contract' +import { AmountInput } from './amount-input' +import { Col } from './layout/col' +import { createAnswer, placeBet, resolveMarket } from '../lib/firebase/api-call' +import { Row } from './layout/row' +import { Avatar } from './avatar' +import { SiteLink } from './site-link' +import { DateTimeTooltip } from './datetime-tooltip' +import dayjs from 'dayjs' +import { BuyButton, ChooseCancelSelector } from './yes-no-selector' +import { Spacer } from './layout/spacer' +import { + formatMoney, + formatPercent, + formatWithCommas, +} from '../../common/util/format' +import { InfoTooltip } from './info-tooltip' +import { useUser } from '../hooks/use-user' +import { + getProbabilityAfterBet, + getOutcomeProbability, + calculateShares, + calculatePayoutAfterCorrectBet, +} from '../../common/calculate' +import { firebaseLogin } from '../lib/firebase/users' +import { Bet } from '../../common/bet' +import { useAnswers } from '../hooks/use-answers' +import { ResolveConfirmationButton } from './confirmation-button' +import { tradingAllowed } from '../lib/firebase/contracts' + +export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) { + const { contract } = props + const { creatorId, resolution } = contract + + const answers = useAnswers(contract.id) ?? props.answers + const [chosenAnswer, otherAnswers] = _.partition( + answers.filter((answer) => answer.id !== '0'), + (answer) => answer.id === resolution + ) + const sortedAnswers = [ + ...chosenAnswer, + ..._.sortBy( + otherAnswers, + (answer) => -1 * getOutcomeProbability(contract.totalShares, answer.id) + ), + ] + + const user = useUser() + + const [resolveOption, setResolveOption] = useState< + 'CHOOSE' | 'CANCEL' | undefined + >() + const [answerChoice, setAnswerChoice] = useState() + + useEffect(() => { + if (resolveOption !== 'CHOOSE' && answerChoice) setAnswerChoice(undefined) + }, [answerChoice, resolveOption]) + + return ( + + {sortedAnswers.map((answer) => ( + setAnswerChoice(answer.id)} + /> + ))} + + {sortedAnswers.length === 0 ? ( +
No answers yet...
+ ) : ( +
+ None of the above:{' '} + {formatPercent(getOutcomeProbability(contract.totalShares, '0'))} +
+ )} + + {tradingAllowed(contract) && } + + {user?.id === creatorId && !resolution && ( + + )} + + ) +} + +function AnswerItem(props: { + answer: Answer + contract: Contract + showChoice: boolean + isChosen: boolean + onChoose: () => void +}) { + const { answer, contract, showChoice, isChosen, onChoose } = props + const { resolution, totalShares } = contract + const { username, avatarUrl, name, createdTime, number, text } = answer + + const createdDate = dayjs(createdTime).format('MMM D') + const prob = getOutcomeProbability(totalShares, answer.id) + const probPercent = formatPercent(prob) + const wasResolvedTo = resolution === answer.id + + const [isBetting, setIsBetting] = useState(false) + + return ( + + +
{text}
+ + + + + +
{name}
+
+
+ +
+ +
+ + {createdDate} + +
+
+
#{number}
+
+ + + {isBetting ? ( + setIsBetting(false)} + /> + ) : ( + + {!wasResolvedTo && ( +
+ {probPercent} +
+ )} + {showChoice ? ( +
+ +
+ ) : ( + <> + {tradingAllowed(contract) && ( + { + setIsBetting(true) + }} + /> + )} + {wasResolvedTo && ( + +
Chosen
+
{probPercent}
+ + )} + + )} +
+ )} + + ) +} + +function AnswerBetPanel(props: { + answer: Answer + contract: Contract + closePanel: () => void +}) { + const { answer, contract, closePanel } = props + const { id: answerId } = answer + + const user = useUser() + const [betAmount, setBetAmount] = useState(undefined) + + const [error, setError] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + + const inputRef = useRef(null) + useEffect(() => { + inputRef.current && inputRef.current.focus() + }, []) + + async function submitBet() { + if (!user || !betAmount) return + + if (user.balance < betAmount) { + setError('Insufficient balance') + return + } + + setError(undefined) + setIsSubmitting(true) + + const result = await placeBet({ + amount: betAmount, + outcome: answerId, + contractId: contract.id, + }).then((r) => r.data as any) + + console.log('placed bet. Result:', result) + + if (result?.status === 'success') { + setIsSubmitting(false) + closePanel() + } else { + setError(result?.error || 'Error placing bet') + setIsSubmitting(false) + } + } + + const betDisabled = isSubmitting || !betAmount || error + + const initialProb = getOutcomeProbability(contract.totalShares, answer.id) + + const resultProb = getProbabilityAfterBet( + contract.totalShares, + answerId, + betAmount ?? 0 + ) + + const shares = calculateShares(contract.totalShares, betAmount ?? 0, answerId) + + const currentPayout = betAmount + ? calculatePayoutAfterCorrectBet(contract, { + outcome: answerId, + amount: betAmount, + shares, + } as Bet) + : 0 + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + + return ( + + +
Buy this answer
+ + +
+
Amount
+ + + + +
Implied probability
+ +
{formatPercent(initialProb)}
+
+
{formatPercent(resultProb)}
+
+ + + + + Payout if chosen + + +
+ {formatMoney(currentPayout)} +   (+{currentReturnPercent}) +
+ + + + {user ? ( + + ) : ( + + )} + + ) +} + +function CreateAnswerInput(props: { contract: Contract }) { + const { contract } = props + const [text, setText] = useState('') + const [betAmount, setBetAmount] = useState(10) + const [amountError, setAmountError] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + + const canSubmit = text && betAmount && !amountError && !isSubmitting + + const submitAnswer = async () => { + if (canSubmit) { + setIsSubmitting(true) + const result = await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }).then((r) => r.data) + + setIsSubmitting(false) + + if (result.status === 'success') { + setText('') + setBetAmount(10) + setAmountError(undefined) + } + } + } + + const resultProb = getProbabilityAfterBet( + contract.totalShares, + 'new', + betAmount ?? 0 + ) + + const shares = calculateShares(contract.totalShares, betAmount ?? 0, 'new') + + const currentPayout = betAmount + ? calculatePayoutAfterCorrectBet(contract, { + outcome: 'new', + amount: betAmount, + shares, + } as Bet) + : 0 + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + + return ( + + +
Add your answer
+