Create answer

This commit is contained in:
James Grugett 2022-02-12 19:25:07 -06:00
parent f42f9d9a1a
commit 893016a7c3
11 changed files with 314 additions and 15 deletions

27
common/calculate-multi.ts Normal file
View File

@ -0,0 +1,27 @@
import * as _ from 'lodash'
export function getMultiProbability(
totalShares: {
[answerId: string]: number
},
answerId: string
) {
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
const shares = totalShares[answerId] ?? 0
return shares ** 2 / squareSum
}
export function calculateMultiShares(
totalShares: {
[answerId: string]: number
},
bet: number,
betChoice: string
) {
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
const shares = totalShares[betChoice] ?? 0
const c = 2 * bet * Math.sqrt(squareSum)
return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares
}

View File

@ -1,9 +1,10 @@
import { Bet } from './bet'
import { calculateShares, getProbability } from './calculate'
import { calculateMultiShares, getMultiProbability } from './calculate-multi'
import { Contract } from './contract'
import { User } from './user'
export const getNewBetInfo = (
export const getNewBinaryBetInfo = (
user: User,
outcome: 'YES' | 'NO',
amount: number,
@ -52,3 +53,43 @@ export const getNewBetInfo = (
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
}
export const getNewMultiBetInfo = (
user: User,
outcome: string,
amount: number,
contract: Contract<'MULTI'>,
newBetId: string
) => {
const { pool, totalShares, totalBets } = contract
const prevOutcomePool = pool[outcome] ?? 0
const newPool = { ...pool, outcome: prevOutcomePool + amount }
const shares = calculateMultiShares(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 = getMultiProbability(totalShares, outcome)
const probAfter = getMultiProbability(newTotalShares, outcome)
const newBet: Bet<'MULTI'> = {
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 }
}

View File

@ -44,6 +44,14 @@ service cloud.firestore {
allow read;
}
match /contracts/{contractId}/answers/{answerId} {
allow read;
}
match /{somePath=**}/answers/{answerId} {
allow read;
}
match /folds/{foldId} {
allow read;
allow update, delete: if request.auth.uid == resource.data.curatorId;

View File

@ -0,0 +1,98 @@
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'
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 > 1000)
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<'MULTI'>
if (
contract.outcomeType !== 'MULTI' ||
contract.outcomes !== 'FREE_ANSWER'
)
return {
status: 'error',
message: 'Requires a multi, free answer contract',
}
const { closeTime } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
const newAnswerDoc = firestore
.collection(`contracts/${contractId}/answers`)
.doc()
const answerId = newAnswerDoc.id
const { username, name, avatarUrl } = user
const answer: Answer = {
id: answerId,
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)
transaction.update(contractDoc, {
pool: newPool,
totalShares: newTotalShares,
totalBets: newTotalBets,
})
transaction.update(userDoc, { balance: newBalance })
return { status: 'success', answerId, betId: newBetDoc.id }
})
}
)
const firestore = admin.firestore()

View File

@ -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'

View File

@ -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 } from '../../common/new-bet'
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
async (
@ -51,7 +51,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
.doc()
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
getNewBetInfo(user, outcome, amount, contract, newBetDoc.id)
getNewBinaryBetInfo(user, outcome, amount, contract, newBetDoc.id)
transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, {

View File

@ -0,0 +1,95 @@
import clsx from 'clsx'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { Answer } from '../../common/answer'
import { Contract } from '../../common/contract'
import { AmountInput } from './amount-input'
import { Col } from './layout/col'
import { createAnswer } from '../lib/firebase/api-call'
export function AnswersPanel(props: {
contract: Contract<'MULTI'>
answers: Answer[]
}) {
const { contract, answers } = props
return (
<Col>
<CreateAnswerInput contract={contract} />
{answers.map((answer) => (
<div>{answer.text}</div>
))}
</Col>
)
}
function CreateAnswerInput(props: { contract: Contract<'MULTI'> }) {
const { contract } = props
const [text, setText] = useState('')
const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [amountError, setAmountError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const canSubmit = text && betAmount && !amountError && !isSubmitting
const submitAnswer = async () => {
if (canSubmit) {
setIsSubmitting(true)
console.log('submitting', { text, betAmount })
const result = await createAnswer({
contractId: contract.id,
text,
amount: betAmount,
}).then((r) => r.data)
console.log('submit complte', result)
setIsSubmitting(false)
if (result.status === 'success') {
setText('')
setBetAmount(10)
setAmountError(undefined)
}
}
}
return (
<Col className="gap-4">
<div className="text-xl text-indigo-700">Add your answer</div>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="textarea textarea-bordered w-full"
placeholder="Type your answer..."
rows={1}
maxLength={10000}
/>
<Col className="gap-2 justify-between self-end sm:flex-row">
{text && (
<Col className="gap-2">
<div className="text-gray-500 text-sm">Bet amount</div>
<AmountInput
amount={betAmount}
onChange={setBetAmount}
error={amountError}
setError={setAmountError}
minimumAmount={10}
disabled={isSubmitting}
/>
</Col>
)}
<button
className={clsx(
'btn btn-sm self-start',
canSubmit ? 'btn-outline' : 'btn-disabled'
)}
disabled={!canSubmit}
onClick={submitAnswer}
>
Submit answer
</button>
</Col>
</Col>
)
}

View File

@ -12,6 +12,7 @@ import {
} from '@heroicons/react/solid'
import dayjs from 'dayjs'
import clsx from 'clsx'
import Textarea from 'react-expanding-textarea'
import { OutcomeLabel } from './outcome-label'
import {
@ -37,7 +38,6 @@ import { useBetsWithoutAntes } from '../hooks/use-bets'
import { Bet } from '../lib/firebase/bets'
import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
import { JoinSpans } from './join-spans'
import Textarea from 'react-expanding-textarea'
import { outcome } from '../../common/contract'
import { fromNow } from '../lib/util/time'
import BetRow from './bet-row'
@ -204,7 +204,7 @@ function EditContract(props: {
) : (
<Row>
<button
className="btn btn-neutral btn-outline btn-sm mt-4"
className="btn btn-neutral btn-outline btn-xs mt-4"
onClick={() => setEditing(true)}
>
{props.buttonText}

View File

@ -18,6 +18,16 @@ export const createFold = cloudFunction<
export const placeBet = cloudFunction('placeBet')
export const createAnswer = cloudFunction<
{ contractId: string; text: string; amount: number },
{
status: 'error' | 'success'
message?: string
answerId?: string
betId?: string
}
>('createAnswer')
export const resolveMarket = cloudFunction('resolveMarket')
export const sellBet = cloudFunction('sellBet')

View File

@ -49,7 +49,7 @@ export function getBinaryProbPercent(contract: Contract) {
return probPercent
}
export function tradingAllowed(contract: Contract) {
export function tradingAllowed(contract: Contract<'BINARY' | 'MULTI'>) {
return (
!contract.isResolved &&
(!contract.closeTime || contract.closeTime > Date.now())
@ -84,7 +84,9 @@ export async function getContractFromSlug(slug: string) {
const q = query(contractCollection, where('slug', '==', slug))
const snapshot = await getDocs(q)
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
return snapshot.empty
? undefined
: (snapshot.docs[0].data() as Contract<'BINARY' | 'MULTI'>)
}
export async function deleteContract(contractId: string) {

View File

@ -26,6 +26,9 @@ import Custom404 from '../404'
import { getFoldsByTags } from '../../lib/firebase/folds'
import { Fold } from '../../../common/fold'
import { useFoldsWithTags } from '../../hooks/use-fold'
import { listAllAnswers } from '../../lib/firebase/answers'
import { Answer } from '../../../common/answer'
import { AnswersPanel } from '../../components/answers-panel'
export async function getStaticProps(props: {
params: { username: string; contractSlug: string }
@ -36,9 +39,12 @@ export async function getStaticProps(props: {
const foldsPromise = getFoldsByTags(contract?.tags ?? [])
const [bets, comments] = await Promise.all([
const [bets, comments, answers] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
contractId && contract.outcomes === 'FREE_ANSWER'
? listAllAnswers(contractId)
: [],
])
const folds = await foldsPromise
@ -50,6 +56,7 @@ export async function getStaticProps(props: {
slug: contractSlug,
bets,
comments,
answers,
folds,
},
@ -66,6 +73,7 @@ export default function ContractPage(props: {
username: string
bets: Bet[]
comments: Comment[]
answers: Answer[]
slug: string
folds: Fold[]
}) {
@ -112,6 +120,10 @@ export default function ContractPage(props: {
comments={comments ?? []}
folds={folds}
/>
{contract.outcomes === 'FREE_ANSWER' && (
<AnswersPanel contract={contract as any} answers={props.answers} />
)}
<BetsSection contract={contract} user={user ?? null} bets={bets} />
</div>
@ -140,6 +152,7 @@ function BetsSection(props: {
bets: Bet[]
}) {
const { contract, user } = props
const isBinary = contract.outcomeType === 'BINARY'
const bets = useBets(contract.id) ?? props.bets
// Decending creation time.
@ -152,13 +165,17 @@ function BetsSection(props: {
return (
<div>
<Title className="px-2" text="Your trades" />
<MyBetsSummary
className="px-2"
contract={contract}
bets={userBets}
showMKT
/>
<Spacer h={6} />
{isBinary && (
<>
<MyBetsSummary
className="px-2"
contract={contract}
bets={userBets}
showMKT
/>
<Spacer h={6} />
</>
)}
<ContractBetsTable contract={contract} bets={userBets} />
<Spacer h={12} />
</div>