diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index b5153355..346fca79 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -147,7 +147,8 @@ function calculateAmountToBuyShares( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { // Search for amount between bounds (0, shares). // Min share price is M$0, and max is M$1 each. @@ -157,7 +158,8 @@ function calculateAmountToBuyShares( amount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) const totalShares = sumBy(takers, (taker) => taker.shares) @@ -169,7 +171,8 @@ export function calculateCpmmSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') @@ -180,15 +183,17 @@ export function calculateCpmmSale( state, shares, oppositeOutcome, - unfilledBets + unfilledBets, + balanceByUserId ) - const { cpmmState, makers, takers, totalFees } = computeFills( + const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills( oppositeOutcome, buyAmount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) // Transform buys of opposite outcome into sells. @@ -211,6 +216,7 @@ export function calculateCpmmSale( fees: totalFees, makers, takers: saleTakers, + ordersToCancel, } } @@ -218,9 +224,16 @@ export function getCpmmProbabilityAfterSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { - const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + const { cpmmState } = calculateCpmmSale( + state, + shares, + outcome, + unfilledBets, + balanceByUserId + ) return getCpmmProbability(cpmmState.pool, cpmmState.p) } diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index bf588345..6cfb0421 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { last, sortBy, sum, sumBy } from 'lodash' +import { last, sortBy, sum, sumBy, uniq } from 'lodash' import { calculatePayout } from './calculate' import { Bet, LimitBet } from './bet' import { Contract, CPMMContract, DPMContract } from './contract' @@ -62,16 +62,28 @@ export const computeBinaryCpmmElasticity = ( const limitBets = bets .filter( (b) => - !b.isFilled && !b.isSold && !b.isRedemption && !b.sale && !b.isCancelled + !b.isFilled && + !b.isSold && + !b.isRedemption && + !b.sale && + !b.isCancelled && + b.limitProb !== undefined ) - .sort((a, b) => a.createdTime - b.createdTime) + .sort((a, b) => a.createdTime - b.createdTime) as LimitBet[] + + const userIds = uniq(limitBets.map((b) => b.userId)) + // Assume all limit orders are good. + const userBalances = Object.fromEntries( + userIds.map((id) => [id, Number.MAX_SAFE_INTEGER]) + ) const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo( 'YES', betAmount, contract, undefined, - limitBets as LimitBet[] + limitBets, + userBalances ) const resultYes = getCpmmProbability(poolY, pY) @@ -80,7 +92,8 @@ export const computeBinaryCpmmElasticity = ( betAmount, contract, undefined, - limitBets as LimitBet[] + limitBets, + userBalances ) const resultNo = getCpmmProbability(poolN, pN) diff --git a/common/calculate.ts b/common/calculate.ts index 6481734f..44dc9113 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -78,7 +78,8 @@ export function calculateShares( export function calculateSaleAmount( contract: Contract, bet: Bet, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' && (contract.outcomeType === 'BINARY' || @@ -87,7 +88,8 @@ export function calculateSaleAmount( contract, Math.abs(bet.shares), bet.outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -102,14 +104,16 @@ export function getProbabilityAfterSale( contract: Contract, outcome: string, shares: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' ? getCpmmProbabilityAfterSale( contract, shares, outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) } diff --git a/common/new-bet.ts b/common/new-bet.ts index e9f5c554..8057cd5b 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -143,7 +143,8 @@ export const computeFills = ( betAmount: number, state: CpmmState, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { if (isNaN(betAmount)) { throw new Error('Invalid bet amount: ${betAmount}') @@ -165,10 +166,12 @@ export const computeFills = ( shares: number timestamp: number }[] = [] + const ordersToCancel: LimitBet[] = [] let amount = betAmount let cpmmState = { pool: state.pool, p: state.p } let totalFees = noFees + const currentBalanceByUserId = { ...balanceByUserId } let i = 0 while (true) { @@ -185,9 +188,20 @@ export const computeFills = ( takers.push(taker) } else { // Matched against bet. + i++ + const { userId } = maker.bet + const makerBalance = currentBalanceByUserId[userId] + + if (floatingGreaterEqual(makerBalance, maker.amount)) { + currentBalanceByUserId[userId] = makerBalance - maker.amount + } else { + // Insufficient balance. Cancel maker bet. + ordersToCancel.push(maker.bet) + continue + } + takers.push(taker) makers.push(maker) - i++ } amount -= taker.amount @@ -195,7 +209,7 @@ export const computeFills = ( if (floatingEqual(amount, 0)) break } - return { takers, makers, totalFees, cpmmState } + return { takers, makers, totalFees, cpmmState, ordersToCancel } } export const getBinaryCpmmBetInfo = ( @@ -203,15 +217,17 @@ export const getBinaryCpmmBetInfo = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { pool, p } = contract - const { takers, makers, cpmmState, totalFees } = computeFills( + const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills( outcome, betAmount, { pool, p }, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) const probBefore = getCpmmProbability(contract.pool, contract.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) @@ -246,6 +262,7 @@ export const getBinaryCpmmBetInfo = ( newP: cpmmState.p, newTotalLiquidity, makers, + ordersToCancel, } } @@ -254,14 +271,16 @@ export const getBinaryBetStats = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { newBet } = getBinaryCpmmBetInfo( outcome, betAmount ?? 0, contract, limitProb, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const remainingMatched = ((newBet.orderAmount ?? 0) - newBet.amount) / diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 96636ca0..1b56c819 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = ( outcome: 'YES' | 'NO', contract: CPMMContract, unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number }, loanPaid: number ) => { const { pool, p } = contract - const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( + const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale( contract, shares, outcome, - unfilledBets + unfilledBets, + balanceByUserId, ) const probBefore = getCpmmProbability(pool, p) @@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = ( fees, makers, takers, + ordersToCancel } } diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index b45809d0..66a6884c 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' -import { LimitBet } from '../../common/bet' -import { QuerySnapshot } from 'firebase-admin/firestore' import { Group } from '../../common/group' import { REFERRAL_AMOUNT } from '../../common/economy' const firestore = admin.firestore() @@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore if (prevUser.referredByUserId !== user.referredByUserId) { await handleUserUpdatedReferral(user, eventId) } - - if (user.balance <= 0) { - await cancelLimitOrders(user.id) - } }) async function handleUserUpdatedReferral(user: User, eventId: string) { @@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { ) }) } - -async function cancelLimitOrders(userId: string) { - const snapshot = (await firestore - .collectionGroup('bets') - .where('userId', '==', userId) - .where('isFilled', '==', false) - .get()) as QuerySnapshot - - await Promise.all( - snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true })) - ) -} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 74df7dc3..50c89912 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -23,6 +23,7 @@ import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' import { addUserToContractFollowers } from './follow-market' +import { filterDefined } from '../../common/util/array' const bodySchema = z.object({ contractId: z.string(), @@ -73,9 +74,11 @@ export const placebet = newEndpoint({}, async (req, auth) => { newTotalLiquidity, newP, makers, + ordersToCancel, } = await (async (): Promise< BetInfo & { makers?: maker[] + ordersToCancel?: LimitBet[] } > => { if ( @@ -99,17 +102,16 @@ export const placebet = newEndpoint({}, async (req, auth) => { limitProb = Math.round(limitProb * 100) / 100 } - const unfilledBetsSnap = await trans.get( - getUnfilledBetsQuery(contractDoc) - ) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + const { unfilledBets, balanceByUserId } = + await getUnfilledBetsAndUserBalances(trans, contractDoc) return getBinaryCpmmBetInfo( outcome, amount, contract, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) } else if ( (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && @@ -152,6 +154,13 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (makers) { updateMakers(makers, betDoc.id, contractDoc, trans) } + if (ordersToCancel) { + for (const bet of ordersToCancel) { + trans.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + } if (newBet.amount !== 0) { trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) @@ -193,13 +202,36 @@ export const placebet = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() -export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { +const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { return contractDoc .collection('bets') .where('isFilled', '==', false) .where('isCancelled', '==', false) as Query } +export const getUnfilledBetsAndUserBalances = async ( + trans: Transaction, + contractDoc: DocumentReference +) => { + const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc)) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + // Get balance of all users with open limit orders. + const userIds = uniq(unfilledBets.map((bet) => bet.userId)) + const userDocs = + userIds.length === 0 + ? [] + : await trans.getAll( + ...userIds.map((userId) => firestore.doc(`users/${userId}`)) + ) + const users = filterDefined(userDocs.map((doc) => doc.data() as User)) + const balanceByUserId = Object.fromEntries( + users.map((user) => [user.id, user.balance]) + ) + + return { unfilledBets, balanceByUserId } +} + type maker = { bet: LimitBet amount: number diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index f2f475cb..0c49bb24 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,6 +1,7 @@ import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' +import { FieldValue } from 'firebase-admin/firestore' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' @@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object' import { log } from './utils' import { Bet } from '../../common/bet' import { floatingEqual, floatingLesserEqual } from '../../common/util/math' -import { getUnfilledBetsQuery, updateMakers } from './place-bet' -import { FieldValue } from 'firebase-admin/firestore' +import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet' import { redeemShares } from './redeem-shares' import { removeUserFromContractFollowers } from './follow-market' @@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] = - await Promise.all([ - transaction.getAll(contractDoc, userDoc), - transaction.get(betsQ), - transaction.get(getUnfilledBetsQuery(contractDoc)), - ]) + const [ + [contractSnap, userSnap], + userBetsSnap, + { unfilledBets, balanceByUserId }, + ] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), + transaction.get(betsQ), + getUnfilledBetsAndUserBalances(transaction, contractDoc), + ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) const contract = contractSnap.data() as Contract const user = userSnap.data() as User @@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => { let loanPaid = saleFrac * loanAmount if (!isFinite(loanPaid)) loanPaid = 0 - const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( - soldShares, - chosenOutcome, - contract, - unfilledBets, - loanPaid - ) + const { newBet, newPool, newP, fees, makers, ordersToCancel } = + getCpmmSellBetInfo( + soldShares, + chosenOutcome, + contract, + unfilledBets, + balanceByUserId, + loanPaid + ) if ( !newP || @@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) + for (const bet of ordersToCancel) { + transaction.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + return { newBet, makers, maxShares, soldShares } }) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 4953fc31..740e24ed 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -2,12 +2,12 @@ import clsx from 'clsx' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' import { MenuIcon } from '@heroicons/react/solid' import { toast } from 'react-hot-toast' +import { XCircleIcon } from '@heroicons/react/outline' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' import { keyBy } from 'lodash' -import { XCircleIcon } from '@heroicons/react/outline' import { Button } from './button' import { updateUser } from 'web/lib/firebase/users' import { leaveGroup } from 'web/lib/firebase/groups' diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 3c401767..82f1789d 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -16,7 +16,7 @@ import { Button } from 'web/components/button' import { BetSignUpPrompt } from './sign-up-prompt' import { User } from 'web/lib/firebase/users' import { SellRow } from './sell-row' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { PlayMoneyDisclaimer } from './play-money-disclaimer' /** Button that opens BetPanel in a new modal */ @@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: { user: User }) { const { contract, user } = props - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) return ( <> @@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: { contract={contract as CPMMBinaryContract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} mobileView={true} /> diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index a8f4d718..7362f144 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input' import { Button } from './button' import { Row } from './layout/row' import { YesNoSelector } from './yes-no-selector' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { useUser } from 'web/hooks/use-user' import { BetSignUpPrompt } from './sign-up-prompt' import { getCpmmProbability } from 'common/calculate-cpmm' @@ -34,14 +34,17 @@ export function BetInline(props: { const [error, setError] = useState() const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) const { newPool, newP } = getBinaryCpmmBetInfo( outcome ?? 'YES', amount ?? 0, contract, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) const resultProb = getCpmmProbability(newPool, newP) useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb]) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 2667a93a..72a4fec3 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares' import { BetSignUpPrompt } from './sign-up-prompt' import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { LimitBets } from './limit-bets' import { PillButton } from './buttons/pill-button' import { YesNoSelector } from './yes-no-selector' @@ -55,7 +55,9 @@ export function BetPanel(props: { const { contract, className } = props const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) @@ -86,12 +88,14 @@ export function BetPanel(props: { contract={contract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} />