From f48ae0170b241e1b6d13149c5d2e4228006b6258 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Fri, 24 Dec 2021 15:06:01 -0600 Subject: [PATCH] Sell bets (#12) * sell bet * dev mode * single-pot no-refund payoff; bet selling * Increase default fetch size 25 -> 99 * Fix about page numbering * Don't flash no markets when loading on tag page. * Change Title to use body font * Make a bunch of predictions at once (#9) * Set up a page to make bulk predictions * Integrate preview into the same card * List created predictions * Make changes per James's comments * Increase the starting balance (#11) * Remove references to paying for our Mantic Dollars * Update simulator to use new calculations * Change simulator random to be evenly random again * Sell bet UI * Migrate contracts and bets script * Add comment to script * bets => trades; exclude sold bets * change sale formula * Change current value to uncapped sell value. * Disable sell button while selling * Update some 'bet' to 'trade' Co-authored-by: Austin Chen Co-authored-by: jahooma --- .firebaserc | 6 +- functions/package.json | 1 + functions/src/index.ts | 3 +- functions/src/place-bet.ts | 33 ++--- functions/src/resolve-market.ts | 23 +-- functions/src/scripts/migrate-contract.ts | 58 ++++++++ functions/src/sell-bet.ts | 162 ++++++++++++++++++++++ functions/src/types/bet.ts | 21 ++- functions/src/types/contract.ts | 2 +- web/components/bet-panel.tsx | 18 +-- web/components/bets-list.tsx | 117 +++++++++++++--- web/components/contract-overview.tsx | 4 +- web/components/contracts-list.tsx | 14 +- web/components/profile-menu.tsx | 6 +- web/lib/calculate.ts | 134 ++++++++++++++++++ web/lib/calculation/contract.ts | 72 ---------- web/lib/firebase/api-call.ts | 6 + web/lib/firebase/bets.ts | 17 ++- web/lib/firebase/contracts.ts | 6 +- web/lib/firebase/init.ts | 40 +++--- web/lib/service/create-contract.ts | 7 +- web/lib/simulator/entries.ts | 17 ++- web/lib/util/format.ts | 4 +- web/pages/[username]/[contractSlug].tsx | 2 +- web/pages/about.tsx | 4 +- web/pages/simulator.tsx | 10 +- web/pages/{bets.tsx => trades.tsx} | 6 +- 27 files changed, 589 insertions(+), 204 deletions(-) create mode 100644 functions/src/scripts/migrate-contract.ts create mode 100644 functions/src/sell-bet.ts create mode 100644 web/lib/calculate.ts delete mode 100644 web/lib/calculation/contract.ts create mode 100644 web/lib/firebase/api-call.ts rename web/pages/{bets.tsx => trades.tsx} (68%) diff --git a/.firebaserc b/.firebaserc index 7594f4c6..0e191214 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,7 @@ { "projects": { - "default": "mantic-markets" + "default": "mantic-markets", + "prod": "mantic-markets", + "dev": "dev-mantic-markets" } -} +} \ No newline at end of file diff --git a/functions/package.json b/functions/package.json index baaac0b4..62dd0c58 100644 --- a/functions/package.json +++ b/functions/package.json @@ -2,6 +2,7 @@ "name": "functions", "scripts": { "build": "tsc", + "watch": "tsc -w", "serve": "yarn build && firebase emulators:start --only functions", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", diff --git a/functions/src/index.ts b/functions/src/index.ts index 5f6cfc99..ee8da84b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,6 +2,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() -export * from './keep-awake' +// export * from './keep-awake' export * from './place-bet' export * from './resolve-market' +export * from './sell-bet' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 9e773848..e6e4d25a 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -43,7 +43,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() - const { newBet, newPool, newDpmWeights, newBalance } = getNewBetInfo( + const { newBet, newPool, newTotalShares, newBalance } = getNewBetInfo( user, outcome, amount, @@ -54,7 +54,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { pool: newPool, - dpmWeights: newDpmWeights, + totalShares: newTotalShares, }) transaction.update(userDoc, { balance: newBalance }) @@ -79,29 +79,19 @@ const getNewBetInfo = ( ? { YES: yesPool + amount, NO: noPool } : { YES: yesPool, NO: noPool + amount } - const dpmWeight = + const shares = outcome === 'YES' - ? (amount * noPool ** 2) / (yesPool ** 2 + amount * yesPool) - : (amount * yesPool ** 2) / (noPool ** 2 + amount * noPool) + ? amount + (amount * noPool ** 2) / (yesPool ** 2 + amount * yesPool) + : amount + (amount * yesPool ** 2) / (noPool ** 2 + amount * noPool) - const { YES: yesWeight, NO: noWeight } = contract.dpmWeights || { - YES: 0, - NO: 0, - } // only nesc for old contracts + const { YES: yesShares, NO: noShares } = contract.totalShares - const newDpmWeights = + const newTotalShares = outcome === 'YES' - ? { YES: yesWeight + dpmWeight, NO: noWeight } - : { YES: yesWeight, NO: noWeight + dpmWeight } + ? { YES: yesShares + shares, NO: noShares } + : { YES: yesShares, NO: noShares + shares } const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) - - const probAverage = - (amount + - noPool * Math.atan(yesPool / noPool) - - noPool * Math.atan((amount + yesPool) / noPool)) / - amount - const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2) const newBet: Bet = { @@ -109,15 +99,14 @@ const getNewBetInfo = ( userId: user.id, contractId: contract.id, amount, - dpmWeight, + shares, outcome, probBefore, - probAverage, probAfter, createdTime: Date.now(), } const newBalance = user.balance - amount - return { newBet, newPool, newDpmWeights, newBalance } + return { newBet, newPool, newTotalShares, newBalance } } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index e20ca354..280af022 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -78,22 +78,27 @@ export const resolveMarket = functions const firestore = admin.firestore() const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => { - const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const [yesBets, noBets] = _.partition( + openBets, + (bet) => bet.outcome === 'YES' + ) - const [pool, winningBets] = + const startPool = contract.startPool.YES + contract.startPool.NO + const truePool = contract.pool.YES + contract.pool.NO - startPool + + const [totalShares, winningBets] = outcome === 'YES' - ? [contract.pool.NO - contract.startPool.NO, yesBets] - : [contract.pool.YES - contract.startPool.YES, noBets] + ? [contract.totalShares.YES, yesBets] + : [contract.totalShares.NO, noBets] - const finalPool = (1 - PLATFORM_FEE - CREATOR_FEE) * pool - const creatorPayout = CREATOR_FEE * pool + const finalPool = (1 - PLATFORM_FEE - CREATOR_FEE) * truePool + const creatorPayout = CREATOR_FEE * truePool console.log('final pool:', finalPool, 'creator fee:', creatorPayout) - const sumWeights = _.sumBy(winningBets, (bet) => bet.dpmWeight) - const winnerPayouts = winningBets.map((bet) => ({ userId: bet.userId, - payout: bet.amount + (bet.dpmWeight / sumWeights) * finalPool, + payout: (bet.shares / totalShares) * finalPool, })) return winnerPayouts.concat([ diff --git a/functions/src/scripts/migrate-contract.ts b/functions/src/scripts/migrate-contract.ts new file mode 100644 index 00000000..eb55783e --- /dev/null +++ b/functions/src/scripts/migrate-contract.ts @@ -0,0 +1,58 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' +import { Bet } from '../types/bet' +import { Contract } from '../types/contract' + +type DocRef = admin.firestore.DocumentReference + +// Generate your own private key, and set the path below: +// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk +const serviceAccount = require('../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json') + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}) +const firestore = admin.firestore() + +async function migrateBet(contractRef: DocRef, bet: Bet) { + const { dpmWeight, amount, id } = bet as Bet & { dpmWeight: number } + const shares = dpmWeight + amount + + await contractRef.collection('bets').doc(id).update({ shares }) +} + +async function migrateContract(contractRef: DocRef, contract: Contract) { + const bets = await contractRef + .collection('bets') + .get() + .then((snap) => snap.docs.map((bet) => bet.data() as Bet)) + + const totalShares = { + YES: _.sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)), + NO: _.sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)), + } + + await contractRef.update({ totalShares }) +} + +async function migrateContracts() { + console.log('Migrating contracts') + + const snapshot = await firestore.collection('contracts').get() + const contracts = snapshot.docs.map((doc) => doc.data() as Contract) + + console.log('Loaded contracts', contracts.length) + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + const betsSnapshot = await contractRef.collection('bets').get() + const bets = betsSnapshot.docs.map((bet) => bet.data() as Bet) + + console.log('contract', contract.question, 'bets', bets.length) + + for (const bet of bets) await migrateBet(contractRef, bet) + await migrateContract(contractRef, contract) + } +} + +if (require.main === module) migrateContracts().then(() => process.exit()) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts new file mode 100644 index 00000000..ba60a5e3 --- /dev/null +++ b/functions/src/sell-bet.ts @@ -0,0 +1,162 @@ +import * as admin from 'firebase-admin' +import * as functions from 'firebase-functions' + +import { CREATOR_FEE, PLATFORM_FEE } from './resolve-market' +import { Bet } from './types/bet' +import { Contract } from './types/contract' +import { User } from './types/user' + +export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + contractId: string + betId: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId, betId } = data + + // 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 + + 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 + + const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) + const betSnap = await transaction.get(betDoc) + if (!betSnap.exists) return { status: 'error', message: 'Invalid bet' } + const bet = betSnap.data() as Bet + + if (bet.isSold) return { status: 'error', message: 'Bet already sold' } + + const newBetDoc = firestore + .collection(`contracts/${contractId}/bets`) + .doc() + + const { newBet, newPool, newTotalShares, newBalance, creatorFee } = + getSellBetInfo(user, bet, contract, newBetDoc.id) + + const creatorDoc = firestore.doc(`users/${contract.creatorId}`) + const creatorSnap = await transaction.get(creatorDoc) + if (creatorSnap.exists) { + const creator = creatorSnap.data() as User + const creatorNewBalance = creator.balance + creatorFee + transaction.update(creatorDoc, { balance: creatorNewBalance }) + } + + transaction.update(betDoc, { isSold: true }) + transaction.create(newBetDoc, newBet) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + }) + transaction.update(userDoc, { balance: newBalance }) + + return { status: 'success' } + }) + } +) + +const firestore = admin.firestore() + +const getSellBetInfo = ( + user: User, + bet: Bet, + contract: Contract, + newBetId: string +) => { + const { id: betId, amount, shares, outcome } = bet + + const { YES: yesPool, NO: noPool } = contract.pool + const { YES: yesStart, NO: noStart } = contract.startPool + const { YES: yesShares, NO: noShares } = contract.totalShares + + const [y, n, s] = [yesPool, noPool, shares] + + const shareValue = + outcome === 'YES' + ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b + (n ** 2 + + s * y + + y ** 2 - + Math.sqrt( + n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) + )) / + (2 * y) + : (y ** 2 + + s * n + + n ** 2 - + Math.sqrt( + y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) + )) / + (2 * n) + + const startPool = yesStart + noStart + const pool = yesPool + noPool - startPool + + const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) + + const f = pool / (probBefore * yesShares + (1 - probBefore) * noShares) + + const myPool = outcome === 'YES' ? yesPool - yesStart : noPool - noStart + + const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool) + + const newPool = + outcome === 'YES' + ? { YES: yesPool - adjShareValue, NO: noPool } + : { YES: yesPool, NO: noPool - adjShareValue } + + const newTotalShares = + outcome === 'YES' + ? { YES: yesShares - shares, NO: noShares } + : { YES: yesShares, NO: noShares - shares } + + const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2) + + const creatorFee = CREATOR_FEE * adjShareValue + const saleAmount = (1 - CREATOR_FEE - PLATFORM_FEE) * adjShareValue + + console.log( + 'SELL M$', + amount, + outcome, + 'for M$', + saleAmount, + 'M$/share:', + f, + 'creator fee: M$', + creatorFee + ) + + const newBet: Bet = { + id: newBetId, + userId: user.id, + contractId: contract.id, + amount: -adjShareValue, + shares: -shares, + outcome, + probBefore, + probAfter, + createdTime: Date.now(), + sale: { + amount: saleAmount, + betId, + }, + } + + const newBalance = user.balance + saleAmount + + return { newBet, newPool, newTotalShares, newBalance, creatorFee } +} diff --git a/functions/src/types/bet.ts b/functions/src/types/bet.ts index 2113f812..8b540165 100644 --- a/functions/src/types/bet.ts +++ b/functions/src/types/bet.ts @@ -2,11 +2,20 @@ export type Bet = { id: string userId: string contractId: string - amount: number // Amount of bet - outcome: 'YES' | 'NO' // Chosen outcome - createdTime: number + + amount: number // bet size; negative if SELL bet + outcome: 'YES' | 'NO' + shares: number // dynamic parimutuel pool weight; negative if SELL bet + probBefore: number - probAverage: number probAfter: number - dpmWeight: number // Dynamic Parimutuel weight -} \ No newline at end of file + + sale?: { + amount: number // amount user makes from sale + betId: string // id of bet being sold + } + + isSold?: boolean // true if this BUY bet has been sold + + createdTime: number +} diff --git a/functions/src/types/contract.ts b/functions/src/types/contract.ts index 8278206b..5f354654 100644 --- a/functions/src/types/contract.ts +++ b/functions/src/types/contract.ts @@ -12,7 +12,7 @@ export type Contract = { startPool: { YES: number; NO: number } pool: { YES: number; NO: number } - dpmWeights: { YES: number; NO: number } + totalShares: { YES: number; NO: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f0f34410..42f3abae 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -12,9 +12,9 @@ import { formatMoney, formatPercent } from '../lib/util/format' import { Title } from './title' import { getProbability, - getDpmWeight, + calculateShares, getProbabilityAfterBet, -} from '../lib/calculation/contract' +} from '../lib/calculate' import { firebaseLogin } from '../lib/firebase/users' export function BetPanel(props: { contract: Contract; className?: string }) { @@ -84,9 +84,9 @@ export function BetPanel(props: { contract: Contract; className?: string }) { betChoice, betAmount ?? 0 ) - const dpmWeight = getDpmWeight(contract.pool, betAmount ?? 0, betChoice) + const shares = calculateShares(contract.pool, betAmount ?? 0, betChoice) - const estimatedWinnings = Math.floor((betAmount ?? 0) + dpmWeight) + const estimatedWinnings = Math.floor(shares) const estimatedReturn = betAmount ? (estimatedWinnings - betAmount) / betAmount : 0 @@ -98,7 +98,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) { - + <Title className="!mt-0 whitespace-nowrap" text="Place a trade" /> <div className="mt-2 mb-1 text-sm text-gray-400">Outcome</div> <YesNoSelector @@ -107,7 +107,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) { onSelect={(choice) => onBetChoice(choice)} /> - <div className="mt-3 mb-1 text-sm text-gray-400">Bet amount</div> + <div className="mt-3 mb-1 text-sm text-gray-400">Amount</div> <Col className="my-2"> <label className="input-group"> <span className="text-sm bg-gray-200">M$</span> @@ -168,18 +168,18 @@ export function BetPanel(props: { contract: Contract; className?: string }) { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Place bet'} + {isSubmitting ? 'Submitting...' : 'Submit trade'} </button> ) : ( <button className="btn mt-4 border-none normal-case text-lg font-medium px-10 bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600" onClick={firebaseLogin} > - Sign in to bet! + Sign in to trade! </button> )} - {wasSubmitted && <div className="mt-4">Bet submitted!</div>} + {wasSubmitted && <div className="mt-4">Trade submitted!</div>} </Col> ) } diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 5e51f141..52de070b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -13,10 +13,13 @@ import { Row } from './layout/row' import { UserLink } from './user-page' import { calculatePayout, + calculateSaleAmount, currentValue, resolvedPayout, -} from '../lib/calculation/contract' +} from '../lib/calculate' import clsx from 'clsx' +import { cloudFunction } from '../lib/firebase/api-call' +import { ConfirmationButton } from './confirmation-button' export function BetsList(props: { user: User }) { const { user } = props @@ -65,19 +68,26 @@ export function BetsList(props: { user: User }) { contracts, (contract) => contract.isResolved ) + const currentBets = _.sumBy(unresolved, (contract) => - _.sumBy(contractBets[contract.id], (bet) => bet.amount) + _.sumBy(contractBets[contract.id], (bet) => { + if (bet.isSold || bet.sale) return 0 + return bet.amount + }) ) const currentBetsValue = _.sumBy(unresolved, (contract) => - _.sumBy(contractBets[contract.id], (bet) => currentValue(contract, bet)) + _.sumBy(contractBets[contract.id], (bet) => { + if (bet.isSold || bet.sale) return 0 + return currentValue(contract, bet) + }) ) return ( <Col className="mt-6 gap-6"> <Row className="gap-8"> <Col> - <div className="text-sm text-gray-500">Active bets</div> + <div className="text-sm text-gray-500">Currently invested</div> <div>{formatMoney(currentBets)}</div> </Col> <Col> @@ -173,16 +183,17 @@ export function MyBetsSummary(props: { const { bets, contract, className } = props const { resolution } = contract - const betsTotal = _.sumBy(bets, (bet) => bet.amount) + const excludeSales = bets.filter((b) => !b.isSold && !b.sale) + const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount) const betsPayout = resolution ? _.sumBy(bets, (bet) => resolvedPayout(contract, bet)) : 0 - const yesWinnings = _.sumBy(bets, (bet) => + const yesWinnings = _.sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'YES') ) - const noWinnings = _.sumBy(bets, (bet) => + const noWinnings = _.sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'NO') ) @@ -190,7 +201,7 @@ export function MyBetsSummary(props: { <Row className={clsx('gap-4 sm:gap-6', className)}> <Col> <div className="text-sm text-gray-500 whitespace-nowrap"> - Total bets + Amount invested </div> <div className="whitespace-nowrap">{formatMoney(betsTotal)}</div> </Col> @@ -228,6 +239,11 @@ export function ContractBetsTable(props: { }) { const { contract, bets, className } = props + const [sales, buys] = _.partition(bets, (bet) => bet.sale) + const salesDict = _.fromPairs( + sales.map((sale) => [sale.sale?.betId ?? '', sale]) + ) + const { isResolved } = contract return ( @@ -237,15 +253,21 @@ export function ContractBetsTable(props: { <tr className="p-2"> <th>Date</th> <th>Outcome</th> - <th>Bet</th> + <th>Amount</th> <th>Probability</th> {!isResolved && <th>Est. max payout</th>} <th>{isResolved ? <>Payout</> : <>Current value</>}</th> + <th></th> </tr> </thead> <tbody> - {bets.map((bet) => ( - <BetRow key={bet.id} bet={bet} contract={contract} /> + {buys.map((bet) => ( + <BetRow + key={bet.id} + bet={bet} + sale={salesDict[bet.id]} + contract={contract} + /> ))} </tbody> </table> @@ -253,14 +275,22 @@ export function ContractBetsTable(props: { ) } -function BetRow(props: { bet: Bet; contract: Contract }) { - const { bet, contract } = props - const { amount, outcome, createdTime, probBefore, probAfter, dpmWeight } = bet +function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { + const { bet, sale, contract } = props + const { + amount, + outcome, + createdTime, + probBefore, + probAfter, + shares, + isSold, + } = bet const { isResolved } = contract return ( <tr> - <td>{dayjs(createdTime).format('MMM D, H:mma')}</td> + <td>{dayjs(createdTime).format('MMM D, h:mma')}</td> <td> <OutcomeLabel outcome={outcome} /> </td> @@ -268,18 +298,63 @@ function BetRow(props: { bet: Bet; contract: Contract }) { <td> {formatPercent(probBefore)} → {formatPercent(probAfter)} </td> - {!isResolved && <td>{formatMoney(amount + dpmWeight)}</td>} + {!isResolved && <td>{formatMoney(shares)}</td>} <td> - {formatMoney( - isResolved - ? resolvedPayout(contract, bet) - : currentValue(contract, bet) - )} + {bet.isSold + ? 'N/A' + : formatMoney( + isResolved + ? resolvedPayout(contract, bet) + : bet.sale + ? bet.sale.amount ?? 0 + : currentValue(contract, bet) + )} </td> + + {sale ? ( + <td>SOLD for {formatMoney(Math.abs(sale.amount))}</td> + ) : ( + !isResolved && + !isSold && ( + <td className="text-neutral"> + <SellButton contract={contract} bet={bet} /> + </td> + ) + )} </tr> ) } +const sellBet = cloudFunction('sellBet') + +function SellButton(props: { contract: Contract; bet: Bet }) { + const { contract, bet } = props + const [isSubmitting, setIsSubmitting] = useState(false) + + return ( + <ConfirmationButton + id={`sell-${bet.id}`} + openModelBtn={{ + className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), + label: 'Sell', + }} + submitBtn={{ className: 'btn-primary' }} + onSubmit={async () => { + setIsSubmitting(true) + await sellBet({ contractId: contract.id, betId: bet.id }) + setIsSubmitting(false) + }} + > + <div className="text-2xl mb-4">Sell</div> + <div> + Do you want to sell your {formatMoney(bet.amount)} position on{' '} + <OutcomeLabel outcome={bet.outcome} /> for{' '} + {formatMoney(calculateSaleAmount(contract, bet))}? + </div> + </ConfirmationButton> + ) +} + function OutcomeLabel(props: { outcome: 'YES' | 'NO' | 'CANCEL' }) { const { outcome } = props diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index ad105c9d..aa8dfe6c 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -90,7 +90,7 @@ export const ContractOverview = (props: { }) => { const { contract, className } = props const { resolution, creatorId } = contract - const { probPercent, volume } = compute(contract) + const { probPercent, truePool } = compute(contract) const user = useUser() const isCreator = user?.id === creatorId @@ -140,7 +140,7 @@ export const ContractOverview = (props: { <ContractDescription contract={contract} isCreator={isCreator} /> {/* Show a delete button for contracts without any trading */} - {isCreator && volume === 0 && ( + {isCreator && truePool === 0 && ( <> <Spacer h={8} /> <button diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index f0d15b7b..74c94ad5 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -17,7 +17,7 @@ import { Linkify } from './linkify' export function ContractDetails(props: { contract: Contract }) { const { contract } = props - const { volume, createdDate, resolvedDate } = compute(contract) + const { truePool, createdDate, resolvedDate } = compute(contract) return ( <Row className="flex-wrap text-sm text-gray-500"> @@ -29,7 +29,7 @@ export function ContractDetails(props: { contract: Contract }) { {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} </div> <div className="mx-2">•</div> - <div className="whitespace-nowrap">{formatMoney(volume)} volume</div> + <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> </Row> ) } @@ -110,14 +110,14 @@ function ContractsGrid(props: { contracts: Contract[] }) { ) } -type Sort = 'createdTime' | 'volume' | 'resolved' | 'all' +type Sort = 'createdTime' | 'pool' | 'resolved' | 'all' export function SearchableGrid(props: { contracts: Contract[] defaultSort?: Sort }) { const { contracts, defaultSort } = props const [query, setQuery] = useState('') - const [sort, setSort] = useState(defaultSort || 'volume') + const [sort, setSort] = useState(defaultSort || 'pool') function check(corpus: String) { return corpus.toLowerCase().includes(query.toLowerCase()) @@ -132,8 +132,8 @@ export function SearchableGrid(props: { if (sort === 'createdTime' || sort === 'resolved' || sort === 'all') { matches.sort((a, b) => b.createdTime - a.createdTime) - } else if (sort === 'volume') { - matches.sort((a, b) => compute(b).volume - compute(a).volume) + } else if (sort === 'pool') { + matches.sort((a, b) => compute(b).truePool - compute(a).truePool) } if (sort !== 'all') { @@ -159,7 +159,7 @@ export function SearchableGrid(props: { value={sort} onChange={(e) => setSort(e.target.value as Sort)} > - <option value="volume">Most traded</option> + <option value="pool">Most traded</option> <option value="createdTime">Newest first</option> <option value="resolved">Resolved</option> <option value="all">All markets</option> diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 21a603da..43022a9e 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -41,8 +41,8 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) { ] : []), { - name: 'Your bets', - href: '/bets', + name: 'Your trades', + href: '/trades', }, { name: 'Your markets', @@ -64,7 +64,7 @@ function ProfileSummary(props: { user: User }) { <div className="rounded-full w-10 h-10 mr-4"> <Image src={user.avatarUrl} width={40} height={40} /> </div> - <div className="truncate" style={{ maxWidth: 175 }}> + <div className="truncate text-left" style={{ maxWidth: 175 }}> {user.name} <div className="text-gray-700 text-sm">{formatMoney(user.balance)}</div> </div> diff --git a/web/lib/calculate.ts b/web/lib/calculate.ts new file mode 100644 index 00000000..13649c06 --- /dev/null +++ b/web/lib/calculate.ts @@ -0,0 +1,134 @@ +import { Bet } from './firebase/bets' +import { Contract } from './firebase/contracts' + +const fees = 0.02 + +export function getProbability(pool: { YES: number; NO: number }) { + const [yesPool, noPool] = [pool.YES, pool.NO] + const numerator = Math.pow(yesPool, 2) + const denominator = Math.pow(yesPool, 2) + Math.pow(noPool, 2) + return numerator / denominator +} + +export function getProbabilityAfterBet( + pool: { YES: number; NO: number }, + outcome: 'YES' | 'NO', + bet: number +) { + const [YES, NO] = [ + pool.YES + (outcome === 'YES' ? bet : 0), + pool.NO + (outcome === 'NO' ? bet : 0), + ] + return getProbability({ YES, NO }) +} + +export function calculateShares( + pool: { YES: number; NO: number }, + bet: number, + betChoice: 'YES' | 'NO' +) { + const [yesPool, noPool] = [pool.YES, pool.NO] + + return betChoice === 'YES' + ? bet + (bet * noPool ** 2) / (yesPool ** 2 + bet * yesPool) + : bet + (bet * yesPool ** 2) / (noPool ** 2 + bet * noPool) +} + +export function calculatePayout( + contract: Contract, + bet: Bet, + outcome: 'YES' | 'NO' | 'CANCEL' +) { + const { amount, outcome: betOutcome, shares } = bet + + if (outcome === 'CANCEL') return amount + if (betOutcome !== outcome) return 0 + + const { totalShares } = contract + + if (totalShares[outcome] === 0) return 0 + + const startPool = contract.startPool.YES + contract.startPool.NO + const pool = contract.pool.YES + contract.pool.NO - startPool + + return (1 - fees) * (shares / totalShares[outcome]) * pool +} + +export function resolvedPayout(contract: Contract, bet: Bet) { + if (contract.resolution) + return calculatePayout(contract, bet, contract.resolution) + throw new Error('Contract was not resolved') +} + +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 + + const { shares, outcome } = bet + + const { YES: yesPool, NO: noPool } = contract.pool + const [y, n, s] = [yesPool, noPool, shares] + + const shareValue = + outcome === 'YES' + ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b + (n ** 2 + + s * y + + y ** 2 - + Math.sqrt( + n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) + )) / + (2 * y) + : (y ** 2 + + s * n + + n ** 2 - + Math.sqrt( + y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) + )) / + (2 * n) + + return (1 - fees) * shareValue +} +export function calculateSaleAmount(contract: Contract, bet: Bet) { + const { shares, outcome } = bet + + const { YES: yesPool, NO: noPool } = contract.pool + const { YES: yesStart, NO: noStart } = contract.startPool + const { YES: yesShares, NO: noShares } = contract.totalShares + + const [y, n, s] = [yesPool, noPool, shares] + + const shareValue = + outcome === 'YES' + ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b + (n ** 2 + + s * y + + y ** 2 - + Math.sqrt( + n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) + )) / + (2 * y) + : (y ** 2 + + s * n + + n ** 2 - + Math.sqrt( + y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) + )) / + (2 * n) + + const startPool = yesStart + noStart + const pool = yesPool + noPool - startPool + + const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) + const f = pool / (probBefore * yesShares + (1 - probBefore) * noShares) + + const myPool = outcome === 'YES' ? yesPool - yesStart : noPool - noStart + + const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool) + + const saleAmount = (1 - fees) * adjShareValue + return saleAmount +} diff --git a/web/lib/calculation/contract.ts b/web/lib/calculation/contract.ts deleted file mode 100644 index 69464beb..00000000 --- a/web/lib/calculation/contract.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Bet } from '../firebase/bets' -import { Contract } from '../firebase/contracts' - -const fees = 0.02 - -export function getProbability(pool: { YES: number; NO: number }) { - const [yesPool, noPool] = [pool.YES, pool.NO] - const numerator = Math.pow(yesPool, 2) - const denominator = Math.pow(yesPool, 2) + Math.pow(noPool, 2) - return numerator / denominator -} - -export function getProbabilityAfterBet( - pool: { YES: number; NO: number }, - outcome: 'YES' | 'NO', - bet: number -) { - const [YES, NO] = [ - pool.YES + (outcome === 'YES' ? bet : 0), - pool.NO + (outcome === 'NO' ? bet : 0), - ] - return getProbability({ YES, NO }) -} - -export function getDpmWeight( - pool: { YES: number; NO: number }, - bet: number, - betChoice: 'YES' | 'NO' -) { - const [yesPool, noPool] = [pool.YES, pool.NO] - - return betChoice === 'YES' - ? (bet * Math.pow(noPool, 2)) / (Math.pow(yesPool, 2) + bet * yesPool) - : (bet * Math.pow(yesPool, 2)) / (Math.pow(noPool, 2) + bet * noPool) -} - -export function calculatePayout( - contract: Contract, - bet: Bet, - outcome: 'YES' | 'NO' | 'CANCEL' -) { - const { amount, outcome: betOutcome, dpmWeight } = bet - - if (outcome === 'CANCEL') return amount - if (betOutcome !== outcome) return 0 - - let { dpmWeights, pool, startPool } = contract - - // Fake data if not set. - if (!dpmWeights) dpmWeights = { YES: 100, NO: 100 } - - // Fake data if not set. - if (!pool) pool = { YES: 100, NO: 100 } - - const otherOutcome = outcome === 'YES' ? 'NO' : 'YES' - const poolSize = pool[otherOutcome] - startPool[otherOutcome] - - return (1 - fees) * (dpmWeight / dpmWeights[outcome]) * poolSize + amount -} -export function resolvedPayout(contract: Contract, bet: Bet) { - if (contract.resolution) - return calculatePayout(contract, bet, contract.resolution) - throw new Error('Contract was not resolved') -} - -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 -} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts new file mode 100644 index 00000000..fc054386 --- /dev/null +++ b/web/lib/firebase/api-call.ts @@ -0,0 +1,6 @@ +import { getFunctions, httpsCallable } from 'firebase/functions' + +const functions = getFunctions() + +export const cloudFunction = (name: string) => httpsCallable(functions, name) + diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 92b33010..b09d8f8b 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -11,12 +11,21 @@ export type Bet = { id: string userId: string contractId: string - amount: number // Amount of bet - outcome: 'YES' | 'NO' // Chosen outcome - dpmWeight: number // Dynamic Parimutuel weight + + amount: number // bet size; negative if SELL bet + outcome: 'YES' | 'NO' + shares: number // dynamic parimutuel pool weight; negative if SELL bet + probBefore: number - probAverage: number probAfter: number + + sale?: { + amount: number // amount user makes from sale + betId: string // id of bet being sold + } + + isSold?: boolean // true if this BUY bet has been sold + createdTime: number } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 652bec4b..f97e111c 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -30,7 +30,7 @@ export type Contract = { startPool: { YES: number; NO: number } pool: { YES: number; NO: number } - dpmWeights: { YES: number; NO: number } + totalShares: { YES: number; NO: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed @@ -48,7 +48,7 @@ export function path(contract: Contract) { export function compute(contract: Contract) { const { pool, startPool, createdTime, resolutionTime, isResolved } = contract - const volume = pool.YES + pool.NO - startPool.YES - startPool.NO + const truePool = pool.YES + pool.NO - startPool.YES - startPool.NO const prob = pool.YES ** 2 / (pool.YES ** 2 + pool.NO ** 2) const probPercent = Math.round(prob * 100) + '%' const startProb = @@ -57,7 +57,7 @@ export function compute(contract: Contract) { const resolvedDate = isResolved ? dayjs(resolutionTime).format('MMM D') : undefined - return { volume, probPercent, startProb, createdDate, resolvedDate } + return { truePool, probPercent, startProb, createdDate, resolvedDate } } const db = getFirestore(app) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index c807de98..c9561861 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,24 +1,28 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp } from 'firebase/app' -const firebaseConfig = { - apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', - authDomain: 'mantic-markets.firebaseapp.com', - projectId: 'mantic-markets', - storageBucket: 'mantic-markets.appspot.com', - messagingSenderId: '128925704902', - appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', - measurementId: 'G-SSFK1Q138D', -} + +export const isProd = process.env.NODE_ENV === 'production' + +const firebaseConfig = isProd + ? { + apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', + authDomain: 'mantic-markets.firebaseapp.com', + projectId: 'mantic-markets', + storageBucket: 'mantic-markets.appspot.com', + messagingSenderId: '128925704902', + appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', + measurementId: 'G-SSFK1Q138D', + } + : { + apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', + authDomain: 'dev-mantic-markets.firebaseapp.com', + projectId: 'dev-mantic-markets', + storageBucket: 'dev-mantic-markets.appspot.com', + messagingSenderId: '134303100058', + appId: '1:134303100058:web:27f9ea8b83347251f80323', + measurementId: 'G-YJC9E37P37', + } // Initialize Firebase export const app = initializeApp(firebaseConfig) export const db = getFirestore(app) - -// try { -// // Note: this is still throwing a console error atm... -// import('firebase/analytics').then((analytics) => { -// analytics.getAnalytics(app) -// }) -// } catch (e) { -// console.warn('Analytics were blocked') -// } diff --git a/web/lib/service/create-contract.ts b/web/lib/service/create-contract.ts index b10cddbf..eba7b0ff 100644 --- a/web/lib/service/create-contract.ts +++ b/web/lib/service/create-contract.ts @@ -2,7 +2,6 @@ import { Contract, getContractFromSlug, pushNewContract, - setContract, } from '../firebase/contracts' import { User } from '../firebase/users' import { randomString } from '../util/random-string' @@ -38,7 +37,7 @@ export async function createContract( startPool: { YES: startYes, NO: startNo }, pool: { YES: startYes, NO: startNo }, - dpmWeights: { YES: 0, NO: 0 }, + totalShares: { YES: 0, NO: 0 }, isResolved: false, // TODO: Set create time to Firestore timestamp @@ -49,8 +48,8 @@ export async function createContract( return await pushNewContract(contract) } -export function calcStartPool(initialProb: number, initialCapital = 200) { - const p = initialProb / 100.0 +export function calcStartPool(initialProbInt: number, initialCapital = 200) { + const p = initialProbInt / 100.0 const startYes = p === 0.5 diff --git a/web/lib/simulator/entries.ts b/web/lib/simulator/entries.ts index df80543b..535a59ad 100644 --- a/web/lib/simulator/entries.ts +++ b/web/lib/simulator/entries.ts @@ -21,10 +21,13 @@ function makeWeights(bids: Bid[]) { // First pass: calculate all the weights for (const { yesBid, noBid } of bids) { const yesWeight = - (yesBid * Math.pow(noPot, 2)) / (Math.pow(yesPot, 2) + yesBid * yesPot) || - 0 + yesBid + + (yesBid * Math.pow(noPot, 2)) / + (Math.pow(yesPot, 2) + yesBid * yesPot) || 0 const noWeight = - (noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || 0 + noBid + + (noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || + 0 // Note: Need to calculate weights BEFORE updating pot yesPot += yesBid @@ -53,15 +56,15 @@ export function makeEntries(bids: Bid[]): Entry[] { const yesWeightsSum = weights.reduce((sum, entry) => sum + entry.yesWeight, 0) const noWeightsSum = weights.reduce((sum, entry) => sum + entry.noWeight, 0) + const potSize = yesPot + noPot - YES_SEED - NO_SEED + // Second pass: calculate all the payouts const entries: Entry[] = [] for (const weight of weights) { const { yesBid, noBid, yesWeight, noWeight } = weight - // Payout: You get your initial bid back, as well as your share of the - // (noPot - seed) according to your yesWeight - const yesPayout = yesBid + (yesWeight / yesWeightsSum) * (noPot - NO_SEED) - const noPayout = noBid + (noWeight / noWeightsSum) * (yesPot - YES_SEED) + const yesPayout = (yesWeight / yesWeightsSum) * potSize + const noPayout = (noWeight / noWeightsSum) * potSize const yesReturn = (yesPayout - yesBid) / yesBid const noReturn = (noPayout - noBid) / noBid entries.push({ ...weight, yesPayout, noPayout, yesReturn, noReturn }) diff --git a/web/lib/util/format.ts b/web/lib/util/format.ts index 5a9e3020..9d0496b2 100644 --- a/web/lib/util/format.ts +++ b/web/lib/util/format.ts @@ -6,11 +6,11 @@ const formatter = new Intl.NumberFormat('en-US', { }) export function formatMoney(amount: number) { - return 'M$ ' + formatter.format(amount).substring(1) + return 'M$ ' + formatter.format(amount).replace('$', '') } export function formatWithCommas(amount: number) { - return formatter.format(amount).substring(1) + return formatter.format(amount).replace('$', '') } export function formatPercent(zeroToOne: number) { diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 3bb7b5ad..60a1a5fb 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -97,7 +97,7 @@ function BetsSection(props: { contract: Contract; user: User | null }) { return ( <div> - <Title text="Your bets" /> + <Title text="Your trades" /> <MyBetsSummary contract={contract} bets={userBets} /> <Spacer h={6} /> <ContractBetsTable contract={contract} bets={userBets} /> diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 61d87516..54aa9453 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -162,8 +162,8 @@ function Contents() { </p> <h3 id="how-are-markets-resolved-">How are markets resolved?</h3> <p> - The creator of the prediction market decides the outcome and earns 0.5% - of the trade volume for their effort. + The creator of the prediction market decides the outcome and earns 1% of + the betting pool for their effort. </p> <p> This simple resolution mechanism has surprising benefits in allowing a diff --git a/web/pages/simulator.tsx b/web/pages/simulator.tsx index 3294f503..3df8a83d 100644 --- a/web/pages/simulator.tsx +++ b/web/pages/simulator.tsx @@ -86,7 +86,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { return ( <> <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${(entry.yesBid + entry.yesWeight).toFixed(0)}</td> + <td>${entry.yesWeight.toFixed(0)}</td> {!props.isNew && ( <> <td>${entry.yesPayout.toFixed(0)}</td> @@ -99,7 +99,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { return ( <> <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${(entry.noBid + entry.noWeight).toFixed(0)}</td> + <td>${entry.noWeight.toFixed(0)}</td> {!props.isNew && ( <> <td>${entry.noPayout.toFixed(0)}</td> @@ -149,9 +149,9 @@ function NewBidTable(props: { function randomBid() { const bidType = Math.random() < 0.5 ? 'YES' : 'NO' - const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob + // const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob - const amount = Math.round(p * Math.random() * 300) + 1 + const amount = Math.floor(Math.random() * 300) + 1 const bid = makeBid(bidType, amount) bids.splice(steps, 0, bid) @@ -238,7 +238,7 @@ function NewBidTable(props: { // Show a hello world React page export default function Simulator() { const [steps, setSteps] = useState(1) - const [bids, setBids] = useState([{ yesBid: 550, noBid: 450 }]) + const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }]) const entries = useMemo( () => makeEntries(bids.slice(0, steps)), diff --git a/web/pages/bets.tsx b/web/pages/trades.tsx similarity index 68% rename from web/pages/bets.tsx rename to web/pages/trades.tsx index bebd044e..6e66d001 100644 --- a/web/pages/bets.tsx +++ b/web/pages/trades.tsx @@ -4,13 +4,13 @@ import { SEO } from '../components/SEO' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' -export default function BetsPage() { +export default function TradesPage() { const user = useUser() return ( <Page> - <SEO title="Your bets" description="Your bets" url="/bets" /> - <Title text="Your bets" /> + <SEO title="Your trades" description="Your trades" url="/trades" /> + <Title text="Your trades" /> {user && <BetsList user={user} />} </Page> )