From ed5f69db7a048d37cc90af7b8a19f135a7022407 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 29 Mar 2022 14:56:56 -0500 Subject: [PATCH] Sell shares panel (#69) * Split BuyAmountInput out of AmountInput * Buy and sell tabs. Compute some sell info * In progress * BuyPanel & SellPanel with banner above that shows current shares and toggle button * Remove "Remaining balance" * Bring back 'Place a trade'. Tweaks * Sell shares cloud function. * Sell all shares by default. Switch back to buy if sell all your shares. * Cache your shares in local storage so sell banner doesn't flicker. * Compute sale value of shares with binary search to keep k constant. * Update bets table to show BUY or SELL * Fixes from Stephen's review * Don't allow selling more than max shares in cloud function * Use modal for sell shares on desktop. * Handle floating point precision in max shares you can sell. --- common/calculate-cpmm.ts | 60 ++- common/sell-bet.ts | 16 +- functions/src/index.ts | 1 + functions/src/sell-bet.ts | 22 +- functions/src/sell-shares.ts | 111 +++++ web/components/amount-input.tsx | 224 +++++++-- web/components/answers/answer-bet-panel.tsx | 4 +- .../answers/create-answer-panel.tsx | 4 +- web/components/bet-panel.tsx | 424 ++++++++++++++++-- web/components/bet-row.tsx | 64 +-- web/components/bets-list.tsx | 6 +- web/components/feed/feed-items.tsx | 3 +- web/components/layout/modal.tsx | 56 +++ web/components/yes-no-selector.tsx | 11 +- web/lib/firebase/api-call.ts | 9 +- web/pages/create.tsx | 4 +- web/pages/make-predictions.tsx | 4 +- 17 files changed, 830 insertions(+), 193 deletions(-) create mode 100644 functions/src/sell-shares.ts create mode 100644 web/components/layout/modal.tsx diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 45de7392..44e195a2 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -114,28 +114,60 @@ export function calculateCpmmPurchase( return { shares, newPool, newP, fees } } -export function calculateCpmmShareValue( +function computeK(y: number, n: number, p: number) { + return y ** p * n ** (1 - p) +} + +function sellSharesK( + y: number, + n: number, + p: number, + s: number, + outcome: 'YES' | 'NO', + b: number +) { + return outcome === 'YES' + ? computeK(y - b + s, n - b, p) + : computeK(y - b, n - b + s, p) +} + +function calculateCpmmShareValue( contract: FullContract, shares: number, - outcome: string + outcome: 'YES' | 'NO' ) { - const { pool } = contract - const { YES: y, NO: n } = pool + const { pool, p } = contract - // TODO: calculate using new function - const poolChange = outcome === 'YES' ? shares + y - n : shares + n - y - const k = y * n - const shareValue = 0.5 * (shares + y + n - Math.sqrt(4 * k + poolChange ** 2)) - return shareValue + const k = computeK(pool.YES, pool.NO, p) + + // Find bet amount that preserves k after selling shares. + let lowAmount = 0 + let highAmount = shares + let mid = 0 + let kGuess = 0 + while (Math.abs(k - kGuess) > 0.00000000001) { + mid = lowAmount + (highAmount - lowAmount) / 2 + kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid) + if (kGuess < k) { + highAmount = mid + } else { + lowAmount = mid + } + } + return mid } export function calculateCpmmSale( contract: FullContract, - bet: Bet + bet: { shares: number; outcome: string } ) { const { shares, outcome } = bet - const rawSaleValue = calculateCpmmShareValue(contract, shares, outcome) + const rawSaleValue = calculateCpmmShareValue( + contract, + Math.abs(shares), + outcome as 'YES' | 'NO' + ) const { fees, remainingBet: saleValue } = getCpmmLiquidityFee( contract, @@ -153,9 +185,11 @@ export function calculateCpmmSale( ? [y + shares - saleValue + fee, n - saleValue + fee] : [y - saleValue + fee, n + shares - saleValue + fee] - const newPool = { YES: newY, NO: newN } + const postBetPool = { YES: newY, NO: newN } - return { saleValue, newPool, fees } + const { newPool, newP } = addCpmmLiquidity(postBetPool, contract.p, fee) + + return { saleValue, newPool, newP, fees } } export function getCpmmProbabilityAfterSale( diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 246e8649..750cfb39 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -85,21 +85,24 @@ export const getSellBetInfo = ( export const getCpmmSellBetInfo = ( user: User, - bet: Bet, + shares: number, + outcome: 'YES' | 'NO', contract: FullContract, newBetId: string ) => { const { pool, p } = contract - const { id: betId, amount, shares, outcome } = bet - const { saleValue, newPool, fees } = calculateCpmmSale(contract, bet) + const { saleValue, newPool, newP, fees } = calculateCpmmSale(contract, { + shares, + outcome, + }) const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, p) console.log( 'SELL M$', - amount, + shares, outcome, 'for M$', saleValue, @@ -117,10 +120,6 @@ export const getCpmmSellBetInfo = ( probBefore, probAfter, createdTime: Date.now(), - sale: { - amount: saleValue, - betId, - }, fees, } @@ -129,6 +128,7 @@ export const getCpmmSellBetInfo = ( return { newBet, newPool, + newP, newBalance, fees, } diff --git a/functions/src/index.ts b/functions/src/index.ts index 5be29b4d..dedf42a1 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -7,6 +7,7 @@ export * from './place-bet' export * from './resolve-market' export * from './stripe' export * from './sell-bet' +export * from './sell-shares' export * from './create-contract' export * from './create-user' export * from './create-fold' diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 4b31cfde..fff88716 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -4,7 +4,7 @@ import * as functions from 'firebase-functions' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' -import { getCpmmSellBetInfo, getSellBetInfo } from '../../common/sell-bet' +import { getSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { Fees } from '../../common/fees' @@ -34,8 +34,14 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { closeTime, mechanism, collectedFees, volume } = contract + + if (mechanism !== 'dpm-2') + return { + status: 'error', + message: 'Sell shares only works with mechanism dpm-2', + } + if (closeTime && Date.now() > closeTime) return { status: 'error', message: 'Trading is closed' } @@ -57,15 +63,7 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( newTotalBets, newBalance, fees, - } = - mechanism === 'dpm-2' - ? getSellBetInfo(user, bet, contract, newBetDoc.id) - : (getCpmmSellBetInfo( - user, - bet, - contract as any, - newBetDoc.id - ) as any) + } = getSellBetInfo(user, bet, contract, newBetDoc.id) if (!isFinite(newBalance)) { throw new Error('Invalid user balance for ' + user.username) @@ -81,7 +79,7 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( totalShares: newTotalShares, totalBets: newTotalBets, collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), - volume: volume + bet.amount, + volume: volume + Math.abs(newBet.amount), }) ) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts new file mode 100644 index 00000000..a7753b0e --- /dev/null +++ b/functions/src/sell-shares.ts @@ -0,0 +1,111 @@ +import * as _ from 'lodash' +import * as admin from 'firebase-admin' +import * as functions from 'firebase-functions' + +import { Binary, CPMM, FullContract } from '../../common/contract' +import { User } from '../../common/user' +import { getCpmmSellBetInfo } from '../../common/sell-bet' +import { addObjects, removeUndefinedProps } from '../../common/util/object' +import { getValues } from './utils' +import { Bet } from '../../common/bet' + +export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + contractId: string + shares: number + outcome: 'YES' | 'NO' + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId, shares, outcome } = 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 FullContract + const { closeTime, mechanism, collectedFees, volume } = contract + + if (mechanism !== 'cpmm-1') + return { + status: 'error', + message: 'Sell shares only works with mechanism cpmm-1', + } + + if (closeTime && Date.now() > closeTime) + return { status: 'error', message: 'Trading is closed' } + + const userBets = await getValues( + contractDoc.collection('bets').where('userId', '==', userId) + ) + + const [yesBets, noBets] = _.partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = [ + _.sumBy(yesBets, (bet) => bet.shares), + _.sumBy(noBets, (bet) => bet.shares), + ] + + const maxShares = outcome === 'YES' ? yesShares : noShares + if (shares > maxShares + 0.000000000001) { + return { + status: 'error', + message: `You can only sell ${maxShares} shares`, + } + } + + const newBetDoc = firestore + .collection(`contracts/${contractId}/bets`) + .doc() + + const { newBet, newPool, newP, newBalance, fees } = getCpmmSellBetInfo( + user, + shares, + outcome, + contract, + newBetDoc.id + ) + + if (!isFinite(newP)) { + return { + status: 'error', + message: 'Trade rejected due to overflow error.', + } + } + + if (!isFinite(newBalance)) { + throw new Error('Invalid user balance for ' + user.username) + } + + transaction.update(userDoc, { balance: newBalance }) + transaction.create(newBetDoc, newBet) + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), + volume: volume + Math.abs(newBet.amount), + }) + ) + + return { status: 'success' } + }) + } +) + +const firestore = admin.firestore() diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 8f20c0ab..e77802bc 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -1,50 +1,39 @@ import clsx from 'clsx' import _ from 'lodash' import { useUser } from '../hooks/use-user' -import { formatMoney } from '../../common/util/format' +import { formatMoney, formatWithCommas } from '../../common/util/format' import { Col } from './layout/col' import { Row } from './layout/row' -import { useUserContractBets } from '../hooks/use-user-bets' -import { MAX_LOAN_PER_CONTRACT } from '../../common/bet' +import { Bet, MAX_LOAN_PER_CONTRACT } from '../../common/bet' import { InfoTooltip } from './info-tooltip' import { Spacer } from './layout/spacer' +import { calculateCpmmSale } from '../../common/calculate-cpmm' +import { Binary, CPMM, FullContract } from '../../common/contract' export function AmountInput(props: { amount: number | undefined onChange: (newAmount: number | undefined) => void error: string | undefined - setError: (error: string | undefined) => void - contractIdForLoan: string | undefined - minimumAmount?: number + label: string disabled?: boolean className?: string inputClassName?: string // Needed to focus the amount input inputRef?: React.MutableRefObject + children?: any }) { const { amount, onChange, error, - setError, - contractIdForLoan, + label, disabled, className, inputClassName, - minimumAmount, inputRef, + children, } = props - const user = useUser() - - const userBets = useUserContractBets(user?.id, contractIdForLoan) ?? [] - const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) - const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0) - - const loanAmount = contractIdForLoan - ? Math.min(amount ?? 0, MAX_LOAN_PER_CONTRACT - prevLoanAmount) - : 0 - const onAmountChange = (str: string) => { if (str.includes('-')) { onChange(undefined) @@ -56,28 +45,12 @@ export function AmountInput(props: { if (amount >= 10 ** 9) return onChange(str ? amount : undefined) - - const loanAmount = contractIdForLoan - ? Math.min(amount, MAX_LOAN_PER_CONTRACT - prevLoanAmount) - : 0 - const amountNetLoan = amount - loanAmount - - if (user && user.balance < amountNetLoan) { - setError('Insufficient balance') - } else if (minimumAmount && amount < minimumAmount) { - setError('Minimum amount: ' + formatMoney(minimumAmount)) - } else { - setError(undefined) - } } - const amountNetLoan = (amount ?? 0) - loanAmount - const remainingBalance = Math.max(0, (user?.balance ?? 0) - amountNetLoan) - return (