Implement selling shares

This commit is contained in:
James Grugett 2022-07-09 14:26:23 -05:00
parent dd9be0376b
commit 5be2ea8583
11 changed files with 249 additions and 193 deletions

View File

@ -26,11 +26,7 @@ export type Bet = {
isAnte?: boolean
isLiquidityProvision?: boolean
isRedemption?: boolean
// A record of each transaction that partially (or fully) fills the bet amount.
// I.e. A limit order could be filled by partially matching with several bets.
// Non-limit orders can also be filled by matching with multiple limit orders.
fills?: fill[]
}
} & Partial<LimitProps>
export type NumericBet = Bet & {
value: number
@ -39,11 +35,16 @@ export type NumericBet = Bet & {
}
// Binary market limit order.
export type LimitBet = Bet & {
export type LimitBet = Bet & LimitProps
type LimitProps = {
orderAmount: number // Amount of limit order.
limitProb: number // [0, 1]. Bet to this probability.
isFilled: boolean // Whether all of the bet amount has been filled.
isCancelled: boolean // Whether to prevent any further fills.
// A record of each transaction that partially (or fully) fills the orderAmount.
// I.e. A limit order could be filled by partially matching with several bets.
// Non-limit orders can also be filled by matching with multiple limit orders.
fills: fill[]
}
@ -53,6 +54,9 @@ export type fill = {
amount: number
shares: number
timestamp: number
// If the fill is a sale, it means the matching bet has shares of the same outcome.
// I.e. -fill.shares === matchedBet.shares
isSale?: boolean
}
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -1,7 +1,9 @@
import { sum, groupBy, mapValues, sumBy, partition } from 'lodash'
import { LimitBet } from './bet'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet'
import { addObjects } from './util/object'
export type CpmmState = {
@ -166,119 +168,85 @@ function binarySearch(
return mid
}
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(
function calculateAmountToBuyShares(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO'
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) {
const { pool, p } = state
// Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each.
return binarySearch(0, shares, (amount) => {
const { takers } = computeFills(
outcome,
amount,
state,
undefined,
unfilledBets
)
// Find bet amount that preserves k after selling shares.
const k = computeK(pool.YES, pool.NO, p)
const otherPool = outcome === 'YES' ? pool.NO : pool.YES
// Constrain the max sale value to the lessor of 1. shares and 2. the other pool.
// This is because 1. the max value per share is M$ 1,
// and 2. The other pool cannot go negative and the sale value is subtracted from it.
// (Without this, there are multiple solutions for the same k.)
let highAmount = Math.min(shares, otherPool)
let lowAmount = 0
let mid = 0
let kGuess = 0
while (true) {
mid = lowAmount + (highAmount - lowAmount) / 2
// Break once we've reached max precision.
if (mid === lowAmount || mid === highAmount) break
kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid)
if (kGuess < k) {
highAmount = mid
} else {
lowAmount = mid
}
}
return mid
const totalShares = sumBy(takers, (taker) => taker.shares)
return totalShares - shares
})
}
export function calculateCpmmSale(
state: CpmmState,
shares: number,
outcome: string
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) {
if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares')
}
const saleValue = calculateCpmmShareValue(
const oppositeOutcome = outcome === 'YES' ? 'NO' : 'YES'
const buyAmount = calculateAmountToBuyShares(
state,
shares,
outcome as 'YES' | 'NO'
oppositeOutcome,
unfilledBets
)
const fees = noFees
const { cpmmState, makers, takers, totalFees } = computeFills(
oppositeOutcome,
buyAmount,
state,
undefined,
unfilledBets
)
// const { fees, remainingBet: saleValue } = getCpmmLiquidityFee(
// contract,
// rawSaleValue,
// outcome === 'YES' ? 'NO' : 'YES'
// )
// Transform buys of opposite outcome into sells.
const saleTakers = takers.map((taker) => ({
...taker,
// You bought opposite shares, which combine with existing shares, removing them.
shares: -taker.shares,
// Opposite shares combine with shares you are selling for M$ of shares.
// You paid taker.amount for the opposite shares.
// Take the negative because this is money you gain.
amount: -(taker.shares - taker.amount),
isSale: true,
}))
const { pool } = state
const { YES: y, NO: n } = pool
const saleValue = -sumBy(saleTakers, (taker) => taker.amount)
const { liquidityFee: fee } = fees
const [newY, newN] =
outcome === 'YES'
? [y + shares - saleValue + fee, n - saleValue + fee]
: [y - saleValue + fee, n + shares - saleValue + fee]
if (newY < 0 || newN < 0) {
console.log('calculateCpmmSale', {
newY,
newN,
y,
n,
shares,
saleValue,
fee,
outcome,
})
throw new Error('Cannot sell more than in pool')
return {
saleValue,
cpmmState,
fees: totalFees,
makers,
takers: saleTakers,
}
const postBetPool = { YES: newY, NO: newN }
const { newPool, newP } = addCpmmLiquidity(postBetPool, state.p, fee)
return { saleValue, newPool, newP, fees }
}
export function getCpmmProbabilityAfterSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO'
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) {
const { newPool } = calculateCpmmSale(state, shares, outcome)
return getCpmmProbability(newPool, state.p)
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
return getCpmmProbability(cpmmState.pool, cpmmState.p)
}
export function getCpmmLiquidity(

View File

@ -1,5 +1,5 @@
import { maxBy } from 'lodash'
import { Bet } from './bet'
import { Bet, LimitBet } from './bet'
import {
calculateCpmmSale,
getCpmmProbability,
@ -74,11 +74,20 @@ export function calculateShares(
: calculateDpmShares(contract.totalShares, bet, betChoice)
}
export function calculateSaleAmount(contract: Contract, bet: Bet) {
export function calculateSaleAmount(
contract: Contract,
bet: Bet,
unfilledBets: LimitBet[]
) {
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue
? calculateCpmmSale(
contract,
Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO',
unfilledBets
).saleValue
: calculateDpmSaleAmount(contract, bet)
}
@ -91,10 +100,16 @@ export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
export function getProbabilityAfterSale(
contract: Contract,
outcome: string,
shares: number
shares: number,
unfilledBets: LimitBet[]
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale(contract, shares, outcome as 'YES' | 'NO')
? getCpmmProbabilityAfterSale(
contract,
shares,
outcome as 'YES' | 'NO',
unfilledBets
)
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
}

View File

@ -135,10 +135,10 @@ const computeFill = (
return { maker, taker }
}
export const getBinaryCpmmBetInfo = (
export const computeFills = (
outcome: 'YES' | 'NO',
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
state: CpmmState,
limitProb: number | undefined,
unfilledBets: LimitBet[]
) => {
@ -157,7 +157,7 @@ export const getBinaryCpmmBetInfo = (
}[] = []
let amount = betAmount
let cpmmState = { pool: contract.pool, p: contract.p }
let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees
let i = 0
@ -185,6 +185,24 @@ export const getBinaryCpmmBetInfo = (
if (floatingEqual(amount, 0)) break
}
return { takers, makers, totalFees, cpmmState }
}
export const getBinaryCpmmBetInfo = (
outcome: 'YES' | 'NO',
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined,
unfilledBets: LimitBet[]
) => {
const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills(
outcome,
betAmount,
{ pool, p },
limitProb,
unfilledBets
)
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -1,4 +1,4 @@
import { Bet } from './bet'
import { Bet, LimitBet } from './bet'
import {
calculateDpmShareValue,
deductDpmFees,
@ -7,6 +7,7 @@ import {
import { calculateCpmmSale, getCpmmProbability } from './calculate-cpmm'
import { CPMMContract, DPMContract } from './contract'
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
import { sumBy } from 'lodash'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
@ -78,19 +79,24 @@ export const getCpmmSellBetInfo = (
shares: number,
outcome: 'YES' | 'NO',
contract: CPMMContract,
prevLoanAmount: number
prevLoanAmount: number,
unfilledBets: LimitBet[]
) => {
const { pool, p } = contract
const { saleValue, newPool, newP, fees } = calculateCpmmSale(
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
contract,
shares,
outcome
outcome,
unfilledBets
)
const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(newPool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
const takerAmount = sumBy(takers, 'amount')
const takerShares = sumBy(takers, 'shares')
console.log(
'SELL M$',
@ -104,20 +110,26 @@ export const getCpmmSellBetInfo = (
const newBet: CandidateBet<Bet> = {
contractId: contract.id,
amount: -saleValue,
shares: -shares,
amount: takerAmount,
shares: takerShares,
outcome,
probBefore,
probAfter,
createdTime: Date.now(),
loanAmount: -loanPaid,
fees,
fills: takers,
isFilled: true,
isCancelled: false,
orderAmount: takerAmount,
}
return {
newBet,
newPool,
newP,
newPool: cpmmState.pool,
newP: cpmmState.p,
fees,
makers,
takers,
}
}

View File

@ -1,6 +1,11 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { FieldValue, Query } from 'firebase-admin/firestore'
import {
DocumentReference,
FieldValue,
Query,
Transaction,
} from 'firebase-admin/firestore'
import { groupBy, mapValues, sumBy } from 'lodash'
import { APIError, newEndpoint, validate } from './api'
@ -70,12 +75,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
makers,
} = await (async (): Promise<
BetInfo & {
makers?: {
bet: LimitBet
amount: number
shares: number
timestamp: number
}[]
makers?: maker[]
}
> => {
if (
@ -83,19 +83,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
mechanism == 'cpmm-1'
) {
const { outcome, limitProb } = validate(binarySchema, req.body)
const boundedLimitProb = limitProb ?? (outcome === 'YES' ? 1 : 0)
const unfilledBetsQuery = contractDoc
.collection('bets')
.where('outcome', '==', outcome === 'YES' ? 'NO' : 'YES')
.where('isFilled', '==', false)
.where('isCancelled', '==', false)
.where(
'limitProb',
outcome === 'YES' ? '<=' : '>=',
boundedLimitProb
) as Query<LimitBet>
const unfilledBetsSnap = await trans.get(unfilledBetsQuery)
const unfilledBetsSnap = await trans.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
return getBinaryCpmmBetInfo(
@ -134,37 +125,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
log('Created new bet document.')
if (makers) {
const makersByBet = groupBy(makers, (maker) => maker.bet.id)
for (const makers of Object.values(makersByBet)) {
const bet = makers[0].bet
const newFills = makers.map((maker) => {
const { amount, shares, timestamp } = maker
return { amount, shares, matchedBetId: betDoc.id, timestamp }
})
const fills = [...bet.fills, ...newFills]
const totalShares = sumBy(fills, 'shares')
const totalAmount = sumBy(fills, 'amount')
const isFilled = floatingEqual(totalAmount, bet.orderAmount)
log('Updated a matched limit bet.')
trans.update(contractDoc.collection('bets').doc(bet.id), {
fills,
isFilled,
amount: totalAmount,
shares: totalShares,
})
}
// Deduct balance of makers.
// TODO: Check if users would go negative from fills and cancel those bets.
const spentByUser = mapValues(
groupBy(makers, (maker) => maker.bet.userId),
(makers) => sumBy(makers, (maker) => maker.amount)
)
for (const [userId, spent] of Object.entries(spentByUser)) {
const userDoc = firestore.collection('users').doc(userId)
trans.update(userDoc, { balance: FieldValue.increment(-spent) })
}
updateMakers(makers, betDoc.id, contractDoc, trans)
}
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
@ -193,3 +154,55 @@ export const placebet = newEndpoint({}, async (req, auth) => {
})
const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc
.collection('bets')
.where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet>
}
type maker = {
bet: LimitBet
amount: number
shares: number
timestamp: number
}
export const updateMakers = (
makers: maker[],
takerBetId: string,
contractDoc: DocumentReference,
trans: Transaction
) => {
const makersByBet = groupBy(makers, (maker) => maker.bet.id)
for (const makers of Object.values(makersByBet)) {
const bet = makers[0].bet
const newFills = makers.map((maker) => {
const { amount, shares, timestamp } = maker
return { amount, shares, matchedBetId: takerBetId, timestamp }
})
const fills = [...bet.fills, ...newFills]
const totalShares = sumBy(fills, 'shares')
const totalAmount = sumBy(fills, 'amount')
const isFilled = floatingEqual(totalAmount, bet.orderAmount)
log('Updated a matched limit bet.')
trans.update(contractDoc.collection('bets').doc(bet.id), {
fills,
isFilled,
amount: totalAmount,
shares: totalShares,
})
}
// Deduct balance of makers.
// TODO: Check if users would go negative from fills and cancel those bets.
const spentByUser = mapValues(
groupBy(makers, (maker) => maker.bet.userId),
(makers) => sumBy(makers, (maker) => maker.amount)
)
for (const [userId, spent] of Object.entries(spentByUser)) {
const userDoc = firestore.collection('users').doc(userId)
trans.update(userDoc, { balance: FieldValue.increment(-spent) })
}
}

View File

@ -9,6 +9,9 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues } from './utils'
import { Bet } from '../../common/bet'
import { floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
const bodySchema = z.object({
contractId: z.string(),
@ -46,14 +49,22 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
if (shares > maxShares)
if (!floatingLesserEqual(shares, maxShares))
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(
shares,
const soldShares = Math.min(shares, maxShares)
const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
outcome,
contract,
prevLoanAmount
prevLoanAmount,
unfilledBets
)
if (
@ -65,11 +76,17 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
}
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0)
const userId = user.id
transaction.update(userDoc, { balance: newBalance })
transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet })
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount),
})
transaction.create(newBetDoc, {
id: newBetDoc.id,
userId: user.id,
...newBet,
})
transaction.update(
contractDoc,
removeUndefinedProps({

View File

@ -497,19 +497,21 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const betDisabled = isSubmitting || !amount || error
// Sell all shares if remaining shares would be < 1
const sellQuantity = amount === Math.floor(shares) ? shares : amount
async function submitSell() {
if (!user || !amount) return
setError(undefined)
setIsSubmitting(true)
// Sell all shares if remaining shares would be < 1
const sellAmount = amount === Math.floor(shares) ? shares : amount
await sellShares({
shares: sellAmount,
shares: sellQuantity,
outcome: sharesOutcome,
contractId: contract.id,
})
@ -534,18 +536,19 @@ export function SellPanel(props: {
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
shares: sellAmount,
shares: sellQuantity,
outcome: sharesOutcome,
})
}
const initialProb = getProbability(contract)
const { newPool } = calculateCpmmSale(
const { cpmmState, saleValue } = calculateCpmmSale(
contract,
Math.min(amount ?? 0, shares),
sharesOutcome
sellQuantity ?? 0,
sharesOutcome,
unfilledBets
)
const resultProb = getCpmmProbability(newPool, contract.p)
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = partition(
@ -557,17 +560,8 @@ export function SellPanel(props: {
sumBy(noBets, (bet) => bet.shares),
]
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
const ownedShares = Math.round(yesShares) || Math.round(noShares)
const sharesSold = Math.min(amount ?? 0, ownedShares)
const { saleValue } = calculateCpmmSale(
contract,
sharesSold,
sellOutcome as 'YES' | 'NO'
)
const onAmountChange = (amount: number | undefined) => {
setAmount(amount)

View File

@ -44,6 +44,8 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all'
@ -531,6 +533,8 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
return (
<div className={clsx('overflow-x-auto', className)}>
{amountRedeemed > 0 && (
@ -577,6 +581,7 @@ export function ContractBetsTable(props: {
saleBet={salesDict[bet.id]}
contract={contract}
isYourBet={isYourBets}
unfilledBets={unfilledBets}
/>
))}
</tbody>
@ -590,8 +595,9 @@ function BetRow(props: {
contract: Contract
saleBet?: Bet
isYourBet: boolean
unfilledBets: LimitBet[]
}) {
const { bet, saleBet, contract, isYourBet } = props
const { bet, saleBet, contract, isYourBet, unfilledBets } = props
const {
amount,
outcome,
@ -621,7 +627,7 @@ function BetRow(props: {
formatMoney(
isResolved
? resolvedPayout(contract, bet)
: calculateSaleAmount(contract, bet)
: calculateSaleAmount(contract, bet, unfilledBets)
)
)
@ -681,9 +687,16 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
outcome === 'NO' ? 'YES' : outcome
)
const outcomeProb = getProbabilityAfterSale(contract, outcome, shares)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const saleAmount = calculateSaleAmount(contract, bet)
const outcomeProb = getProbabilityAfterSale(
contract,
outcome,
shares,
unfilledBets
)
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets)
const profit = saleAmount - bet.amount
return (

View File

@ -27,6 +27,7 @@ import { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUnfilledBets } from 'web/hooks/use-bets'
const BET_SIZE = 10
@ -36,6 +37,7 @@ export function QuickBet(props: { contract: Contract; user: User }) {
const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const topAnswer =
outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
@ -85,13 +87,14 @@ export function QuickBet(props: { contract: Contract; user: User }) {
const maxSharesSold = BET_SIZE / (sellOutcome === 'YES' ? prob : 1 - prob)
sharesSold = Math.min(oppositeShares, maxSharesSold)
const { newPool, saleValue } = calculateCpmmSale(
const { cpmmState, saleValue } = calculateCpmmSale(
contract,
sharesSold,
sellOutcome
sellOutcome,
unfilledBets
)
saleAmount = saleValue
previewProb = getCpmmProbability(newPool, contract.p)
previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
}
}

View File

@ -172,7 +172,6 @@ export function listenForUnfilledBets(
) {
const betsQuery = query(
collection(db, 'contracts', contractId, 'bets'),
where('contractId', '==', contractId),
where('isFilled', '==', false),
where('isCancelled', '==', false)
)