From 80ae551ca9bdf125a4e384603488b17488a00df8 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 10 Jul 2022 13:05:44 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=BE=20Limit=20orders!=20=20(#495)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Simple limit order UI * Update bet schema * Restrict bet panel / bet row to only CPMMBinaryContracts (all binary DPM are resolved) * Limit orders partway implemented * Update follow leaderboard copy * Change cpmm code to take some state instead of whole contract * Write more of matching algorithm * Fill in more of placebet * Use client side contract search for emulator * More correct matching * Merge branch 'main' into limit-orders * Some cleanup * Listen for unfilled bets in bet panel. Calculate how the probability moves based on open limit orders. * Simpler switching between bet & limit bet. * Render your open bets (unfilled limit orders) * Cancel bet endpoint. * Fix build error * Rename open bets to limit bets. Tweak payout calculation * Limit probability selector to 1-99 * Deduct user balance only on each fill. Store orderAmount of bet. Timestamp of fills. * Use floating equal to check if have shares * Add limit order switcher to mobile bet dialog * Support limit orders on numeric markets * Allow CORS exception for Vercel deployments * Remove console.logs * Update user balance by new bet amount * Tweak vercel cors * Try another regexp for vercel cors * Test another vercel regex * Slight notifications refactor * Fix docs edit link (#624) * Fix docs edit link * Update github links * Small groups UX changes * Groups UX on mobile * Leaderboards => Rankings on groups * Unused vars * create: remove automatic setting of log scale * Use react-query to cache notifications (#625) * Use react-query to cache notifications * Fix imports * Cleanup * Limit unseen notifs query * Catch the bounced query * Don't use interval * Unused var * Avoid flash of page nav * Give notification question priority & 2 lines * Right justify timestamps * Rewording * Margin * Simplify error msg * Be explicit about limit for unseen notifs * Pass limit > 0 * Remove category filters * Remove category selector references * Track notification clicks * Analyze tab usage * Bold more on new group chats * Add API route for listing a bets by user (#567) * Add API route for getting a user's bets * Refactor bets API to use /bets * Update /markets to use zod validation * Update docs * Clone missing indexes from firestore * Minor notif spacing adjustments * Enable tipping on group chats w/ notif (#629) * Tweak cors regex for vercel * Your limit bets * Implement selling shares * Merge branch 'main' into limit-orders * Fix lint * Move binary search to util file * Add note that there might be closed form * Add tooltip to explain limit probability * Tweak * Cancel your limit orders if you run out of money * Don't show amount error in probability input * Require limit prob to be >= .1% and <= 99.9% * Fix focus input bug * Simplify mobile betting dialog * Move mobile limit bets list into bet dialog. * Small fixes to existing sell shares client * Lint * Refactor useSaveShares to actually read from localStorage, use less bug-prone interface. * Fix NaN error * Remove TODO * Simple bet fill notification * Tweak wording * Sort limit bets by limit prob * Padding on limit bets * Match header size Co-authored-by: Ian Philips Co-authored-by: ahalekelly Co-authored-by: mantikoros Co-authored-by: Ben Congdon Co-authored-by: Austin Chen --- common/bet.ts | 30 +- common/calculate-cpmm.ts | 215 +++++----- common/calculate.ts | 30 +- common/envs/constants.ts | 4 + common/new-bet.ts | 235 +++++++++-- common/notification.ts | 1 + common/sell-bet.ts | 30 +- common/util/algos.ts | 22 + common/util/math.ts | 14 + functions/src/api.ts | 3 +- functions/src/cancel-bet.ts | 35 ++ functions/src/create-notification.ts | 36 +- functions/src/index.ts | 1 + functions/src/on-create-bet.ts | 51 ++- functions/src/on-update-user.ts | 18 + functions/src/place-bet.ts | 104 ++++- functions/src/sell-shares.ts | 33 +- web/components/bet-panel.tsx | 390 +++++++++--------- web/components/bet-row.tsx | 21 +- web/components/bets-list.tsx | 35 +- web/components/bucket-input.tsx | 9 +- web/components/contract/contract-card.tsx | 5 +- web/components/contract/contract-overview.tsx | 9 +- web/components/contract/quick-bet.tsx | 51 ++- web/components/feed/contract-activity.tsx | 4 +- web/components/feed/feed-items.tsx | 7 +- web/components/limit-bets.tsx | 89 ++++ web/components/numeric-resolution-panel.tsx | 2 +- web/components/probability-input.tsx | 49 +++ web/components/sell-row.tsx | 17 +- web/components/use-save-binary-shares.ts | 56 +++ web/components/use-save-shares.ts | 59 --- web/hooks/use-bets.ts | 11 + web/hooks/use-focus.ts | 5 +- web/lib/firebase/api-call.ts | 4 + web/lib/firebase/bets.ts | 17 +- web/pages/[username]/[contractSlug].tsx | 7 +- web/pages/embed/[username]/[contractSlug].tsx | 7 +- web/pages/notifications.tsx | 15 +- 39 files changed, 1209 insertions(+), 522 deletions(-) create mode 100644 common/util/algos.ts create mode 100644 functions/src/cancel-bet.ts create mode 100644 web/components/limit-bets.tsx create mode 100644 web/components/probability-input.tsx create mode 100644 web/components/use-save-binary-shares.ts delete mode 100644 web/components/use-save-shares.ts diff --git a/common/bet.ts b/common/bet.ts index 993a2fac..d5072c0f 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,6 +4,7 @@ export type Bet = { id: string userId: string contractId: string + createdTime: number amount: number // bet size; negative if SELL bet loanAmount?: number @@ -25,9 +26,7 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean - - createdTime: number -} +} & Partial export type NumericBet = Bet & { value: number @@ -35,4 +34,29 @@ export type NumericBet = Bet & { allBetAmounts: { [outcome: string]: number } } +// Binary market limit order. +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[] +} + +export type fill = { + // The id the bet matched against, or null if the bet was matched by the pool. + matchedBetId: string | null + 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 diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 66162132..493b5fa9 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,10 +1,17 @@ -import { sum, groupBy, mapValues, sumBy, zip } from 'lodash' +import { sum, groupBy, mapValues, sumBy } from 'lodash' +import { LimitBet } from './bet' -import { CPMMContract } from './contract' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { LiquidityProvision } from './liquidity-provision' +import { computeFills } from './new-bet' +import { binarySearch } from './util/algos' import { addObjects } from './util/object' +export type CpmmState = { + pool: { [outcome: string]: number } + p: number +} + export function getCpmmProbability( pool: { [outcome: string]: number }, p: number @@ -14,11 +21,11 @@ export function getCpmmProbability( } export function getCpmmProbabilityAfterBetBeforeFees( - contract: CPMMContract, + state: CpmmState, outcome: string, bet: number ) { - const { pool, p } = contract + const { pool, p } = state const shares = calculateCpmmShares(pool, p, bet, outcome) const { YES: y, NO: n } = pool @@ -31,12 +38,12 @@ export function getCpmmProbabilityAfterBetBeforeFees( } export function getCpmmOutcomeProbabilityAfterBet( - contract: CPMMContract, + state: CpmmState, outcome: string, bet: number ) { - const { newPool } = calculateCpmmPurchase(contract, bet, outcome) - const p = getCpmmProbability(newPool, contract.p) + const { newPool } = calculateCpmmPurchase(state, bet, outcome) + const p = getCpmmProbability(newPool, state.p) return outcome === 'NO' ? 1 - p : p } @@ -58,12 +65,8 @@ function calculateCpmmShares( : n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p)) } -export function getCpmmFees( - contract: CPMMContract, - bet: number, - outcome: string -) { - const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet) +export function getCpmmFees(state: CpmmState, bet: number, outcome: string) { + const prob = getCpmmProbabilityAfterBetBeforeFees(state, outcome, bet) const betP = outcome === 'YES' ? 1 - prob : prob const liquidityFee = LIQUIDITY_FEE * betP * bet @@ -78,23 +81,23 @@ export function getCpmmFees( } export function calculateCpmmSharesAfterFee( - contract: CPMMContract, + state: CpmmState, bet: number, outcome: string ) { - const { pool, p } = contract - const { remainingBet } = getCpmmFees(contract, bet, outcome) + const { pool, p } = state + const { remainingBet } = getCpmmFees(state, bet, outcome) return calculateCpmmShares(pool, p, remainingBet, outcome) } export function calculateCpmmPurchase( - contract: CPMMContract, + state: CpmmState, bet: number, outcome: string ) { - const { pool, p } = contract - const { remainingBet, fees } = getCpmmFees(contract, bet, outcome) + const { pool, p } = state + const { remainingBet, fees } = getCpmmFees(state, bet, outcome) const shares = calculateCpmmShares(pool, p, remainingBet, outcome) const { YES: y, NO: n } = pool @@ -113,117 +116,111 @@ export function calculateCpmmPurchase( return { shares, newPool, newP, fees } } -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: CPMMContract, - shares: number, +// Note: there might be a closed form solution for this. +// If so, feel free to switch out this implementation. +export function calculateCpmmAmountToProb( + state: CpmmState, + prob: number, outcome: 'YES' | 'NO' ) { - const { pool, p } = contract + if (outcome === 'NO') prob = 1 - prob - // 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 + // First, find an upper bound that leads to a more extreme probability than prob. + let maxGuess = 10 + let newProb = 0 + do { + maxGuess *= 10 + newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, maxGuess) + } while (newProb < prob) - // 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 + // Then, binary search for the amount that gets closest to prob. + const amount = binarySearch(0, maxGuess, (amount) => { + const newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, amount) + return newProb - prob + }) - // Break once we've reached max precision. - if (mid === lowAmount || mid === highAmount) break + return amount +} - kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid) - if (kGuess < k) { - highAmount = mid - } else { - lowAmount = mid - } - } - return mid +function calculateAmountToBuyShares( + state: CpmmState, + shares: number, + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] +) { + // 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 + ) + + const totalShares = sumBy(takers, (taker) => taker.shares) + return totalShares - shares + }) } export function calculateCpmmSale( - contract: CPMMContract, + 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 rawSaleValue = calculateCpmmShareValue( - contract, + const oppositeOutcome = outcome === 'YES' ? 'NO' : 'YES' + const buyAmount = calculateAmountToBuyShares( + state, shares, - outcome as 'YES' | 'NO' + oppositeOutcome, + unfilledBets ) - const { fees, remainingBet: saleValue } = getCpmmFees( - contract, - rawSaleValue, - outcome === 'YES' ? 'NO' : 'YES' + const { cpmmState, makers, takers, totalFees } = computeFills( + oppositeOutcome, + buyAmount, + state, + undefined, + unfilledBets ) - const { pool } = contract - const { YES: y, NO: n } = pool + // 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 { liquidityFee: fee } = fees + const saleValue = -sumBy(saleTakers, (taker) => taker.amount) - 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, contract.p, fee) - - return { saleValue, newPool, newP, fees } } export function getCpmmProbabilityAfterSale( - contract: CPMMContract, + state: CpmmState, shares: number, - outcome: 'YES' | 'NO' + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] ) { - const { newPool } = calculateCpmmSale(contract, shares, outcome) - return getCpmmProbability(newPool, contract.p) + const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + return getCpmmProbability(cpmmState.pool, cpmmState.p) } export function getCpmmLiquidity( @@ -267,11 +264,11 @@ const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { } export function getCpmmLiquidityPoolWeights( - contract: CPMMContract, + state: CpmmState, liquidities: LiquidityProvision[], excludeAntes: boolean ) { - const calcLiqudity = calculateLiquidityDelta(contract.p) + const calcLiqudity = calculateLiquidityDelta(state.p) const liquidityShares = liquidities.map(calcLiqudity) const shareSum = sum(liquidityShares) @@ -293,16 +290,12 @@ export function getCpmmLiquidityPoolWeights( export function getUserLiquidityShares( userId: string, - contract: CPMMContract, + state: CpmmState, liquidities: LiquidityProvision[], excludeAntes: boolean ) { - const weights = getCpmmLiquidityPoolWeights( - contract, - liquidities, - excludeAntes - ) + const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes) const userWeight = weights[userId] ?? 0 - return mapValues(contract.pool, (shares) => userWeight * shares) + return mapValues(state.pool, (shares) => userWeight * shares) } diff --git a/common/calculate.ts b/common/calculate.ts index 482a0ccf..e1f3e239 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,5 +1,5 @@ import { maxBy } from 'lodash' -import { Bet } from './bet' +import { Bet, LimitBet } from './bet' import { calculateCpmmSale, getCpmmProbability, @@ -24,6 +24,7 @@ import { FreeResponseContract, PseudoNumericContract, } from './contract' +import { floatingEqual } from './util/math' export function getProbability( contract: BinaryContract | PseudoNumericContract @@ -73,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) } @@ -90,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) } @@ -157,7 +173,9 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some((shares) => shares > 0) + const hasShares = Object.values(totalShares).some( + (shares) => !floatingEqual(shares, 0) + ) return { invested: Math.max(0, currentInvested), diff --git a/common/envs/constants.ts b/common/envs/constants.ts index c03c44bc..7092d711 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -34,5 +34,9 @@ export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' ) +// Vercel deployments, used for testing. +export const CORS_ORIGIN_VERCEL = new RegExp( + '^https?://[a-zA-Z0-9\\-]+' + escapeRegExp('mantic.vercel.app') + '$' +) // Any localhost server on any port export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ diff --git a/common/new-bet.ts b/common/new-bet.ts index 57739af3..6c3e6856 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,6 +1,6 @@ -import { sumBy } from 'lodash' +import { sortBy, sumBy } from 'lodash' -import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' +import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { calculateDpmShares, getDpmProbability, @@ -8,7 +8,12 @@ import { getNumericBets, calculateNumericDpmShares, } from './calculate-dpm' -import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' +import { + calculateCpmmAmountToProb, + calculateCpmmPurchase, + CpmmState, + getCpmmProbability, +} from './calculate-cpmm' import { CPMMBinaryContract, DPMBinaryContract, @@ -17,8 +22,13 @@ import { PseudoNumericContract, } from './contract' import { noFees } from './fees' -import { addObjects } from './util/object' +import { addObjects, removeUndefinedProps } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' +import { + floatingEqual, + floatingGreaterEqual, + floatingLesserEqual, +} from './util/math' export type CandidateBet = Omit export type BetInfo = { @@ -30,38 +40,203 @@ export type BetInfo = { newP?: number } -export const getNewBinaryCpmmBetInfo = ( - outcome: 'YES' | 'NO', +const computeFill = ( amount: number, - contract: CPMMBinaryContract | PseudoNumericContract, - loanAmount: number + outcome: 'YES' | 'NO', + limitProb: number | undefined, + cpmmState: CpmmState, + matchedBet: LimitBet | undefined ) => { - const { shares, newPool, newP, fees } = calculateCpmmPurchase( - contract, - amount, - outcome - ) + const prob = getCpmmProbability(cpmmState.pool, cpmmState.p) - const { pool, p, totalLiquidity } = contract - const probBefore = getCpmmProbability(pool, p) - const probAfter = getCpmmProbability(newPool, newP) - - const newBet: CandidateBet = { - contractId: contract.id, - amount, - shares, - outcome, - fees, - loanAmount, - probBefore, - probAfter, - createdTime: Date.now(), + if ( + limitProb !== undefined && + (outcome === 'YES' + ? floatingGreaterEqual(prob, limitProb) && + (matchedBet?.limitProb ?? 1) > limitProb + : floatingLesserEqual(prob, limitProb) && + (matchedBet?.limitProb ?? 0) < limitProb) + ) { + // No fill. + return undefined } - const { liquidityFee } = fees - const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee + const timestamp = Date.now() - return { newBet, newPool, newP, newTotalLiquidity } + if ( + !matchedBet || + (outcome === 'YES' + ? prob < matchedBet.limitProb + : prob > matchedBet.limitProb) + ) { + // Fill from pool. + const limit = !matchedBet + ? limitProb + : outcome === 'YES' + ? Math.min(matchedBet.limitProb, limitProb ?? 1) + : Math.max(matchedBet.limitProb, limitProb ?? 0) + + const buyAmount = + limit === undefined + ? amount + : Math.min(amount, calculateCpmmAmountToProb(cpmmState, limit, outcome)) + + const { shares, newPool, newP, fees } = calculateCpmmPurchase( + cpmmState, + buyAmount, + outcome + ) + const newState = { pool: newPool, p: newP } + + return { + maker: { + matchedBetId: null, + shares, + amount: buyAmount, + state: newState, + fees, + timestamp, + }, + taker: { + matchedBetId: null, + shares, + amount: buyAmount, + timestamp, + }, + } + } + + // Fill from matchedBet. + const matchRemaining = matchedBet.orderAmount - matchedBet.amount + const shares = Math.min( + amount / + (outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb), + matchRemaining / + (outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb) + ) + + const maker = { + bet: matchedBet, + matchedBetId: 'taker', + amount: + shares * + (outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb), + shares, + timestamp, + } + const taker = { + matchedBetId: matchedBet.id, + amount: + shares * + (outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb), + shares, + timestamp, + } + return { maker, taker } +} + +export const computeFills = ( + outcome: 'YES' | 'NO', + betAmount: number, + state: CpmmState, + limitProb: number | undefined, + unfilledBets: LimitBet[] +) => { + const sortedBets = sortBy( + unfilledBets.filter((bet) => bet.outcome !== outcome), + (bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb), + (bet) => bet.createdTime + ) + + const takers: fill[] = [] + const makers: { + bet: LimitBet + amount: number + shares: number + timestamp: number + }[] = [] + + let amount = betAmount + let cpmmState = { pool: state.pool, p: state.p } + let totalFees = noFees + + let i = 0 + while (true) { + const matchedBet: LimitBet | undefined = sortedBets[i] + const fill = computeFill(amount, outcome, limitProb, cpmmState, matchedBet) + if (!fill) break + + const { taker, maker } = fill + + if (maker.matchedBetId === null) { + // Matched against pool. + cpmmState = maker.state + totalFees = addObjects(totalFees, maker.fees) + takers.push(taker) + } else { + // Matched against bet. + takers.push(taker) + makers.push(maker) + i++ + } + + amount -= taker.amount + + 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) + + const takerAmount = sumBy(takers, 'amount') + const takerShares = sumBy(takers, 'shares') + const isFilled = floatingEqual(betAmount, takerAmount) + + const newBet: CandidateBet = removeUndefinedProps({ + orderAmount: betAmount, + amount: takerAmount, + shares: takerShares, + limitProb, + isFilled, + isCancelled: false, + fills: takers, + contractId: contract.id, + outcome, + probBefore, + probAfter, + loanAmount: 0, + createdTime: Date.now(), + fees: totalFees, + }) + + const { liquidityFee } = totalFees + const newTotalLiquidity = (contract.totalLiquidity ?? 0) + liquidityFee + + return { + newBet, + newPool: cpmmState.pool, + newP: cpmmState.p, + newTotalLiquidity, + makers, + } } export const getNewBinaryDpmBetInfo = ( diff --git a/common/notification.ts b/common/notification.ts index da8a045a..63a44a52 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -62,3 +62,4 @@ export type notification_reason_types = | 'unique_bettors_on_your_contract' | 'on_group_you_are_member_of' | 'tip_received' + | 'bet_fill' diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 6d487ff2..e1fd9c5d 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -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 = Omit @@ -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 = { 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, } } diff --git a/common/util/algos.ts b/common/util/algos.ts new file mode 100644 index 00000000..dd450075 --- /dev/null +++ b/common/util/algos.ts @@ -0,0 +1,22 @@ +export function binarySearch( + min: number, + max: number, + comparator: (x: number) => number +) { + let mid = 0 + while (true) { + mid = min + (max - min) / 2 + + // Break once we've reached max precision. + if (mid === min || mid === max) break + + const comparison = comparator(mid) + if (comparison === 0) break + else if (comparison > 0) { + max = mid + } else { + min = mid + } + } + return mid +} diff --git a/common/util/math.ts b/common/util/math.ts index 66bcff1b..fb07afed 100644 --- a/common/util/math.ts +++ b/common/util/math.ts @@ -34,3 +34,17 @@ export function median(xs: number[]) { export function average(xs: number[]) { return sum(xs) / xs.length } + +const EPSILON = 0.00000001 + +export function floatingEqual(a: number, b: number, epsilon = EPSILON) { + return Math.abs(a - b) < epsilon +} + +export function floatingGreaterEqual(a: number, b: number, epsilon = EPSILON) { + return a + epsilon >= b +} + +export function floatingLesserEqual(a: number, b: number, epsilon = EPSILON) { + return a - epsilon <= b +} diff --git a/functions/src/api.ts b/functions/src/api.ts index 290ea3d8..6ebffc24 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -8,6 +8,7 @@ import { PrivateUser } from '../../common/user' import { CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST, + CORS_ORIGIN_VERCEL, } from '../../common/envs/constants' type Output = Record @@ -118,7 +119,7 @@ const DEFAULT_OPTS = { concurrency: 100, memory: '2GiB', cpu: 1, - cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_VERCEL, CORS_ORIGIN_LOCALHOST], } export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { diff --git a/functions/src/cancel-bet.ts b/functions/src/cancel-bet.ts new file mode 100644 index 00000000..27e65ffb --- /dev/null +++ b/functions/src/cancel-bet.ts @@ -0,0 +1,35 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' +import { LimitBet } from '../../common/bet' + +const bodySchema = z.object({ + betId: z.string(), +}) + +export const cancelbet = newEndpoint({}, async (req, auth) => { + const { betId } = validate(bodySchema, req.body) + + const result = await firestore.runTransaction(async (trans) => { + const snap = await trans.get( + firestore.collectionGroup('bets').where('id', '==', betId) + ) + const betDoc = snap.docs[0] + if (!betDoc?.exists) throw new APIError(400, 'Bet not found.') + + const bet = betDoc.data() as LimitBet + if (bet.userId !== auth.uid) + throw new APIError(400, 'Not authorized to cancel bet.') + if (bet.limitProb === undefined) + throw new APIError(400, 'Not a limit bet: Cannot cancel.') + if (bet.isCancelled) throw new APIError(400, 'Bet already cancelled.') + + trans.update(betDoc.ref, { isCancelled: true }) + + return { ...bet, isCancelled: true } + }) + + return result +}) + +const firestore = admin.firestore() diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 519720fd..0d3432a7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -10,7 +10,7 @@ import { Contract } from '../../common/contract' import { getUserByUsername, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' -import { Bet } from '../../common/bet' +import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' @@ -382,3 +382,37 @@ export const createTipNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createBetFillNotification = async ( + fromUser: User, + toUser: User, + bet: Bet, + userBet: LimitBet, + contract: Contract, + idempotencyKey: string +) => { + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) + const fillAmount = fill?.amount ?? 0 + + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: 'bet_fill', + createdTime: Date.now(), + isSeen: false, + sourceId: userBet.id, + sourceType: 'bet', + sourceUpdateType: 'updated', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: fillAmount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 35f29954..0d0de3ba 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,7 @@ export * from './transact' export * from './change-user-info' export * from './create-answer' export * from './place-bet' +export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' export * from './claim-manalink' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 3e615e42..5789ed0b 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -1,7 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { keyBy } from 'lodash' -import { Bet } from '../../common/bet' +import { Bet, LimitBet } from '../../common/bet' +import { getContract, getUser, getValues } from './utils' +import { createBetFillNotification } from './create-notification' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() @@ -11,6 +15,8 @@ export const onCreateBet = functions.firestore const { contractId } = context.params as { contractId: string } + const { eventId } = context + const bet = change.data() as Bet const lastBetTime = bet.createdTime @@ -18,4 +24,47 @@ export const onCreateBet = functions.firestore .collection('contracts') .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) + + await notifyFills(bet, contractId, eventId) }) + +const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { + if (!bet.fills) return + + const user = await getUser(bet.userId) + if (!user) return + const contract = await getContract(contractId) + if (!contract) return + + const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) + const matchedBets = ( + await Promise.all( + matchedFills.map((fill) => + getValues( + firestore.collectionGroup('bets').where('id', '==', fill.matchedBetId) + ) + ) + ) + ).flat() + + const betUsers = await Promise.all( + matchedBets.map((bet) => getUser(bet.userId)) + ) + const betUsersById = keyBy(filterDefined(betUsers), 'id') + + await Promise.all( + matchedBets.map((matchedBet) => { + const matchedUser = betUsersById[matchedBet.userId] + if (!matchedUser) return + + return createBetFillNotification( + user, + matchedUser, + bet, + matchedBet, + contract, + eventId + ) + }) + ) +} diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index b6ba6e0b..0ace3c53 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,6 +5,8 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' +import { LimitBet } from 'common/bet' +import { QuerySnapshot } from 'firebase-admin/firestore' const firestore = admin.firestore() export const onUpdateUser = functions.firestore @@ -17,6 +19,10 @@ 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) { @@ -109,3 +115,15 @@ 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 43906f3c..52daf953 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -1,17 +1,25 @@ import * as admin from 'firebase-admin' import { z } from 'zod' +import { + DocumentReference, + FieldValue, + Query, + Transaction, +} from 'firebase-admin/firestore' +import { groupBy, mapValues, sumBy } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { BetInfo, - getNewBinaryCpmmBetInfo, - getNewBinaryDpmBetInfo, + getBinaryCpmmBetInfo, getNewMultiBetInfo, getNumericBetsInfo, } from '../../common/new-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' +import { LimitBet } from '../../common/bet' +import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' @@ -22,6 +30,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(['YES', 'NO']), + limitProb: z.number().gte(0.001).lte(0.999).optional(), }) const freeResponseSchema = z.object({ @@ -63,16 +72,30 @@ export const placebet = newEndpoint({}, async (req, auth) => { newTotalBets, newTotalLiquidity, newP, - } = await (async (): Promise => { - if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { - const { outcome } = validate(binarySchema, req.body) - return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if ( - (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + makers, + } = await (async (): Promise< + BetInfo & { + makers?: maker[] + } + > => { + if ( + (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && mechanism == 'cpmm-1' ) { - const { outcome } = validate(binarySchema, req.body) - return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) + const { outcome, limitProb } = validate(binarySchema, req.body) + + const unfilledBetsSnap = await trans.get( + getUnfilledBetsQuery(contractDoc) + ) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + return getBinaryCpmmBetInfo( + outcome, + amount, + contract, + limitProb, + unfilledBets + ) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { const { outcome } = validate(freeResponseSchema, req.body) const answerDoc = contractDoc.collection('answers').doc(outcome) @@ -97,11 +120,15 @@ export const placebet = newEndpoint({}, async (req, auth) => { throw new APIError(400, 'Bet too large for current liquidity pool.') } - const newBalance = user.balance - amount - loanAmount const betDoc = contractDoc.collection('bets').doc() trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) log('Created new bet document.') - trans.update(userDoc, { balance: newBalance }) + + if (makers) { + updateMakers(makers, betDoc.id, contractDoc, trans) + } + + trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) log('Updated user balance.') trans.update( contractDoc, @@ -112,7 +139,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { totalBets: newTotalBets, totalLiquidity: newTotalLiquidity, collectedFees: addObjects(newBet.fees, collectedFees), - volume: volume + amount, + volume: volume + newBet.amount, }) ) log('Updated contract properties.') @@ -127,3 +154,54 @@ 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 +} + +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. + 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) }) + } +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 62e43105..3407760b 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -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({ diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index a43f6f12..271eeecc 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,13 +1,10 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' +import { SwitchHorizontalIcon } from '@heroicons/react/solid' import { useUser } from 'web/hooks/use-user' -import { - BinaryContract, - CPMMBinaryContract, - PseudoNumericContract, -} from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -18,20 +15,16 @@ import { formatPercent, formatWithCommas, } from 'common/util/format' +import { getBinaryCpmmBetInfo } from 'common/new-bet' import { Title } from './title' import { User } from 'web/lib/firebase/users' -import { Bet } from 'common/bet' +import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' -import { - calculatePayoutAfterCorrectBet, - calculateShares, - getProbability, - getOutcomeProbabilityAfterBet, -} from 'common/calculate' +import { BinaryOutcomeLabel } from './outcome-label' +import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { @@ -39,178 +32,153 @@ import { getCpmmProbability, getCpmmFees, } from 'common/calculate-cpmm' -import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { + getFormattedMappedValue, + getPseudoProbability, +} from 'common/pseudo-numeric' import { SellRow } from './sell-row' -import { useSaveShares } from './use-save-shares' +import { useSaveBinaryShares } from './use-save-binary-shares' import { SignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' +import { ProbabilityInput } from './probability-input' import { track } from 'web/lib/service/analytics' +import { removeUndefinedProps } from 'common/util/object' +import { useUnfilledBets } from 'web/hooks/use-bets' +import { LimitBets } from './limit-bets' +import { BucketInput } from './bucket-input' export function BetPanel(props: { - contract: BinaryContract | PseudoNumericContract + contract: CPMMBinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) - const sharesOutcome = yesFloorShares - ? 'YES' - : noFloorShares - ? 'NO' - : undefined + const unfilledBets = useUnfilledBets(contract.id) ?? [] + const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) + const { sharesOutcome } = useSaveBinaryShares(contract, userBets) + + const [isLimitOrder, setIsLimitOrder] = useState(false) return ( -
Place your bet
- {/* */} + <Row className="align-center justify-between"> + <div className="mb-6 text-2xl"> + {isLimitOrder ? <>Limit bet</> : <>Place your bet</>} + </div> + <button + className="btn btn-ghost btn-sm text-sm normal-case" + onClick={() => setIsLimitOrder(!isLimitOrder)} + > + <SwitchHorizontalIcon className="inline h-6 w-6" /> + </button> + </Row> - <BuyPanel contract={contract} user={user} /> + <BuyPanel + contract={contract} + user={user} + isLimitOrder={isLimitOrder} + unfilledBets={unfilledBets} + /> <SignUpPrompt /> </Col> + {yourUnfilledBets.length > 0 && ( + <LimitBets + className="mt-4" + contract={contract} + bets={yourUnfilledBets} + /> + )} </Col> ) } -export function BetPanelSwitcher(props: { - contract: BinaryContract | PseudoNumericContract +export function SimpleBetPanel(props: { + contract: CPMMBinaryContract | PseudoNumericContract className?: string - title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' onBetSuccess?: () => void }) { - const { contract, className, title, selected, onBetSuccess } = props - - const { mechanism, outcomeType } = contract - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const { contract, className, selected, onBetSuccess } = props const user = useUser() - const userBets = useUserContractBets(user?.id, contract.id) + const [isLimitOrder, setIsLimitOrder] = useState(false) - const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY') - - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets - ) - - const floorShares = yesFloorShares || noFloorShares - const sharesOutcome = yesFloorShares - ? 'YES' - : noFloorShares - ? 'NO' - : undefined - - useEffect(() => { - // Switch back to BUY if the user has sold all their shares. - if (tradeType === 'SELL' && sharesOutcome === undefined) { - setTradeType('BUY') - } - }, [tradeType, sharesOutcome]) + const unfilledBets = useUnfilledBets(contract.id) ?? [] + const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) return ( <Col className={className}> - {sharesOutcome && mechanism === 'cpmm-1' && ( - <Col className="rounded-t-md bg-gray-100 px-6 py-6"> - <Row className="items-center justify-between gap-2"> - <div> - You have {formatWithCommas(floorShares)}{' '} - {isPseudoNumeric ? ( - <PseudoNumericOutcomeLabel outcome={sharesOutcome} /> - ) : ( - <BinaryOutcomeLabel outcome={sharesOutcome} /> - )}{' '} - shares - </div> - - {tradeType === 'BUY' && ( - <button - className="btn btn-sm" - style={{ - backgroundColor: 'white', - border: '2px solid', - color: '#3D4451', - }} - onClick={() => - tradeType === 'BUY' - ? setTradeType('SELL') - : setTradeType('BUY') - } - > - {tradeType === 'BUY' ? 'Sell' : 'Bet'} - </button> - )} - </Row> - </Col> - )} - - <Col - className={clsx( - 'rounded-b-md bg-white px-8 py-6', - !sharesOutcome && 'rounded-t-md' - )} - > - <Title - className={clsx( - '!mt-0', - tradeType === 'BUY' && title ? '!text-xl' : '' - )} - text={tradeType === 'BUY' ? title ?? 'Place a trade' : 'Sell shares'} - /> - - {tradeType === 'SELL' && - mechanism == 'cpmm-1' && - user && - sharesOutcome && ( - <SellPanel - contract={contract} - shares={yesShares || noShares} - sharesOutcome={sharesOutcome} - user={user} - userBets={userBets ?? []} - onSellSuccess={onBetSuccess} - /> - )} - - {tradeType === 'BUY' && ( - <BuyPanel - contract={contract} - user={user} - selected={selected} - onBuySuccess={onBetSuccess} + <Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}> + <Row className="justify-between"> + <Title + className={clsx('!mt-0')} + text={isLimitOrder ? 'Limit bet' : 'Place a trade'} /> - )} + + <button + className="btn btn-ghost btn-sm text-sm normal-case" + onClick={() => setIsLimitOrder(!isLimitOrder)} + > + <SwitchHorizontalIcon className="inline h-6 w-6" /> + </button> + </Row> + + <BuyPanel + contract={contract} + user={user} + unfilledBets={unfilledBets} + selected={selected} + onBuySuccess={onBetSuccess} + isLimitOrder={isLimitOrder} + /> <SignUpPrompt /> </Col> + + {yourUnfilledBets.length > 0 && ( + <LimitBets + className="mt-4" + contract={contract} + bets={yourUnfilledBets} + /> + )} </Col> ) } function BuyPanel(props: { - contract: BinaryContract | PseudoNumericContract + contract: CPMMBinaryContract | PseudoNumericContract user: User | null | undefined + unfilledBets: Bet[] + isLimitOrder?: boolean selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { - const { contract, user, selected, onBuySuccess } = props + const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } = + props + + const initialProb = getProbability(contract) const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) + const [limitProb, setLimitProb] = useState<number | undefined>( + Math.round(100 * initialProb) + ) const [error, setError] = useState<string | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) @@ -240,15 +208,22 @@ function BuyPanel(props: { async function submitBet() { if (!user || !betAmount) return + if (isLimitOrder && limitProb === undefined) return + + const limitProbScaled = + isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined setError(undefined) setIsSubmitting(true) - placeBet({ - amount: betAmount, - outcome: betChoice, - contractId: contract.id, - }) + placeBet( + removeUndefinedProps({ + amount: betAmount, + outcome: betChoice, + contractId: contract.id, + limitProb: limitProbScaled, + }) + ) .then((r) => { console.log('placed bet. Result:', r) setIsSubmitting(false) @@ -278,42 +253,31 @@ function BuyPanel(props: { const betDisabled = isSubmitting || !betAmount || error - const initialProb = getProbability(contract) + const limitProbFrac = (limitProb ?? 0) / 100 - const outcomeProb = getOutcomeProbabilityAfterBet( + const { newPool, newP, newBet } = getBinaryCpmmBetInfo( + betChoice ?? 'YES', + betAmount ?? 0, contract, - betChoice || 'YES', - betAmount ?? 0 + isLimitOrder ? limitProbFrac : undefined, + unfilledBets as LimitBet[] ) - const resultProb = betChoice === 'NO' ? 1 - outcomeProb : outcomeProb - const shares = calculateShares(contract, betAmount ?? 0, betChoice || 'YES') - - const currentPayout = betAmount - ? calculatePayoutAfterCorrectBet(contract, { - outcome: betChoice, - amount: betAmount, - shares, - } as Bet) + const resultProb = getCpmmProbability(newPool, newP) + const remainingMatched = isLimitOrder + ? ((newBet.orderAmount ?? 0) - newBet.amount) / + (betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac) : 0 + const currentPayout = newBet.shares + remainingMatched const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) - const cpmmFees = - contract.mechanism === 'cpmm-1' && - getCpmmFees(contract, betAmount ?? 0, betChoice ?? 'YES').totalFees - - const dpmTooltip = - contract.mechanism === 'dpm-2' - ? `Current payout for ${formatWithCommas(shares)} / ${formatWithCommas( - shares + - contract.totalShares[betChoice ?? 'YES'] - - (contract.phantomShares - ? contract.phantomShares[betChoice ?? 'YES'] - : 0) - )} ${betChoice ?? 'YES'} shares` - : undefined + const cpmmFees = getCpmmFees( + contract, + betAmount ?? 0, + betChoice ?? 'YES' + ).totalFees const format = getFormattedMappedValue(contract) @@ -336,29 +300,62 @@ function BuyPanel(props: { disabled={isSubmitting} inputRef={inputRef} /> - + {isLimitOrder && ( + <> + <Row className="my-3 items-center gap-2 text-left text-sm text-gray-500"> + Limit {isPseudoNumeric ? 'value' : 'probability'} + <InfoTooltip + text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${ + isPseudoNumeric ? 'value' : 'probability' + } and wait to match other bets.`} + /> + </Row> + {isPseudoNumeric ? ( + <BucketInput + contract={contract} + onBucketChange={(value) => + setLimitProb( + value === undefined + ? undefined + : 100 * + getPseudoProbability( + value, + contract.min, + contract.max, + contract.isLogScale + ) + ) + } + isSubmitting={isSubmitting} + /> + ) : ( + <ProbabilityInput + inputClassName="w-full max-w-none" + prob={limitProb} + onChange={setLimitProb} + disabled={isSubmitting} + /> + )} + </> + )} <Col className="mt-3 w-full gap-3"> - <Row className="items-center justify-between text-sm"> - <div className="text-gray-500"> - {isPseudoNumeric ? 'Estimated value' : 'Probability'} - </div> - <div> - {format(initialProb)} - <span className="mx-2">→</span> - {format(resultProb)} - </div> - </Row> + {!isLimitOrder && ( + <Row className="items-center justify-between text-sm"> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> + <div> + {format(initialProb)} + <span className="mx-2">→</span> + {format(resultProb)} + </div> + </Row> + )} <Row className="items-center justify-between gap-2 text-sm"> <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> <div> - {contract.mechanism === 'dpm-2' ? ( - <> - Estimated - <br /> payout if{' '} - <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> - </> - ) : isPseudoNumeric ? ( + {isPseudoNumeric ? ( 'Max payout' ) : ( <> @@ -366,14 +363,9 @@ function BuyPanel(props: { </> )} </div> - - {cpmmFees !== false && ( - <InfoTooltip - text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`} - /> - )} - - {dpmTooltip && <InfoTooltip text={dpmTooltip} />} + <InfoTooltip + text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`} + /> </Row> <div> <span className="mr-2 whitespace-nowrap"> @@ -424,19 +416,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, }) @@ -461,18 +455,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( @@ -484,17 +479,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) diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index ae5e0b00..712d4a2c 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -1,18 +1,18 @@ import { useState } from 'react' import clsx from 'clsx' -import { BetPanelSwitcher } from './bet-panel' +import { SimpleBetPanel } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract, PseudoNumericContract } from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' -import { useSaveShares } from './use-save-shares' +import { useSaveBinaryShares } from './use-save-binary-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract | PseudoNumericContract + contract: CPMMBinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -24,10 +24,8 @@ export default function BetRow(props: { ) const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets - ) + const { yesShares, noShares, hasYesShares, hasNoShares } = + useSaveBinaryShares(contract, userBets) return ( <> @@ -40,7 +38,7 @@ export default function BetRow(props: { setBetChoice(choice) }} replaceNoButton={ - yesFloorShares > 0 ? ( + hasYesShares ? ( <SellButton panelClassName={betPanelClassName} contract={contract} @@ -51,7 +49,7 @@ export default function BetRow(props: { ) : undefined } replaceYesButton={ - noFloorShares > 0 ? ( + hasNoShares ? ( <SellButton panelClassName={betPanelClassName} contract={contract} @@ -63,10 +61,9 @@ export default function BetRow(props: { } /> <Modal open={open} setOpen={setOpen}> - <BetPanelSwitcher + <SimpleBetPanel className={betPanelClassName} contract={contract} - title={contract.question} selected={betChoice} onBetSuccess={() => setOpen(false)} /> diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index b8fb7d31..72ac23db 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -44,6 +44,9 @@ 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' +import { floatingEqual } from 'common/util/math' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' @@ -390,6 +393,12 @@ export function BetsSummary(props: { const [showSellModal, setShowSellModal] = useState(false) const user = useUser() + const sharesOutcome = floatingEqual(totalShares.YES, 0) + ? floatingEqual(totalShares.NO, 0) + ? undefined + : 'NO' + : 'YES' + return ( <Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}> <Row className="flex-wrap gap-4 sm:gap-6"> @@ -469,6 +478,7 @@ export function BetsSummary(props: { !isClosed && !resolution && hasShares && + sharesOutcome && user && ( <> <button @@ -482,8 +492,8 @@ export function BetsSummary(props: { contract={contract} user={user} userBets={bets} - shares={totalShares.YES || totalShares.NO} - sharesOutcome={totalShares.YES ? 'YES' : 'NO'} + shares={totalShares[sharesOutcome]} + sharesOutcome={sharesOutcome} setOpen={setShowSellModal} /> )} @@ -505,7 +515,7 @@ export function ContractBetsTable(props: { const { contract, className, isYourBets } = props const bets = sortBy( - props.bets.filter((b) => !b.isAnte), + props.bets.filter((b) => !b.isAnte && b.amount !== 0), (bet) => bet.createdTime ).reverse() @@ -531,6 +541,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 +589,7 @@ export function ContractBetsTable(props: { saleBet={salesDict[bet.id]} contract={contract} isYourBet={isYourBets} + unfilledBets={unfilledBets} /> ))} </tbody> @@ -590,8 +603,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 +635,7 @@ function BetRow(props: { formatMoney( isResolved ? resolvedPayout(contract, bet) - : calculateSaleAmount(contract, bet) + : calculateSaleAmount(contract, bet, unfilledBets) ) ) @@ -681,9 +695,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 ( diff --git a/web/components/bucket-input.tsx b/web/components/bucket-input.tsx index 86456bff..195032dc 100644 --- a/web/components/bucket-input.tsx +++ b/web/components/bucket-input.tsx @@ -1,12 +1,12 @@ import { useState } from 'react' -import { NumericContract } from 'common/contract' +import { NumericContract, PseudoNumericContract } from 'common/contract' import { getMappedBucket } from 'common/calculate-dpm' import { NumberInput } from './number-input' export function BucketInput(props: { - contract: NumericContract + contract: NumericContract | PseudoNumericContract isSubmitting?: boolean onBucketChange: (value?: number, bucket?: string) => void }) { @@ -24,7 +24,10 @@ export function BucketInput(props: { return } - const bucket = getMappedBucket(value, contract) + const bucket = + contract.outcomeType === 'PSEUDO_NUMERIC' + ? '' + : getMappedBucket(value, contract) onBucketChange(value, bucket) } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index c6cda43c..30c54363 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -52,10 +52,7 @@ export function ContractCard(props: { const showQuickBet = user && !marketClosed && - !( - outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined - ) && - outcomeType !== 'NUMERIC' && + (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && !hideQuickBet return ( diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 897bef04..1fc8e077 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -16,7 +16,7 @@ import { import { Bet } from 'common/bet' import BetRow from '../bet-row' import { AnswersGraph } from '../answers/answers-graph' -import { Contract } from 'common/contract' +import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { ShareMarket } from '../share-market' @@ -70,6 +70,13 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + {tradingAllowed(contract) && ( + <BetRow contract={contract as CPMMBinaryContract} /> + )} + </Row> + ) : isPseudoNumeric ? ( + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && <BetRow contract={contract} />} </Row> ) : isPseudoNumeric ? ( diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 76ee7536..0ce1c3f5 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -7,7 +7,13 @@ import { } from 'common/calculate' import { getExpectedValue } from 'common/calculate-dpm' import { User } from 'common/user' -import { Contract, NumericContract, resolution } from 'common/contract' +import { + BinaryContract, + Contract, + NumericContract, + PseudoNumericContract, + resolution, +} from 'common/contract' import { formatLargeNumber, formatMoney, @@ -22,33 +28,30 @@ import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon' import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' import { OUTCOME_TO_COLOR } from '../outcome-label' -import { useSaveShares } from '../use-save-shares' +import { useSaveBinaryShares } from '../use-save-binary-shares' 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 -export function QuickBet(props: { contract: Contract; user: User }) { +export function QuickBet(props: { + contract: BinaryContract | PseudoNumericContract + user: User +}) { const { contract, user } = props const { mechanism, outcomeType } = contract const isCpmm = mechanism === 'cpmm-1' const userBets = useUserContractBets(user.id, contract.id) - const topAnswer = - outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined + const unfilledBets = useUnfilledBets(contract.id) ?? [] - // TODO: yes/no from useSaveShares doesn't work on numeric contracts - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets, - topAnswer?.number.toString() || undefined - ) - const hasUpShares = - yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC') - const hasDownShares = - noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC' + const { hasYesShares, hasNoShares, yesShares, noShares } = + useSaveBinaryShares(contract, userBets) + const hasUpShares = hasYesShares + const hasDownShares = hasNoShares && !hasUpShares const [upHover, setUpHover] = useState(false) const [downHover, setDownHover] = useState(false) @@ -85,13 +88,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) } } @@ -131,13 +135,6 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } - if (outcomeType === 'FREE_RESPONSE') - return ( - <Col className="relative -my-4 -mr-5 min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle"> - <QuickOutcomeView contract={contract} previewProb={previewProb} /> - </Col> - ) - return ( <Col className={clsx( @@ -158,7 +155,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { {formatMoney(10)} </div> - {hasUpShares > 0 ? ( + {hasUpShares ? ( <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', @@ -193,7 +190,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { onMouseLeave={() => setDownHover(false)} onClick={() => placeQuickBet('DOWN')} ></div> - {hasDownShares > 0 ? ( + {hasDownShares ? ( <TriangleDownFillIcon className={clsx( 'mx-auto h-5 w-5', diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 8f728d39..c60afa70 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -31,7 +31,9 @@ export function ContractActivity(props: { const comments = updatedComments ?? props.comments const updatedBets = useBets(contract.id) - const bets = (updatedBets ?? props.bets).filter((bet) => !bet.isRedemption) + const bets = (updatedBets ?? props.bets).filter( + (bet) => !bet.isRedemption && bet.amount !== 0 + ) const items = getSpecificContractActivityItems( contract, bets, diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 312190e4..a9618f8c 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -34,7 +34,7 @@ import { TruncatedComment, } from 'web/components/feed/feed-comments' import { FeedBet } from 'web/components/feed/feed-bets' -import { NumericContract } from 'common/contract' +import { CPMMBinaryContract, NumericContract } from 'common/contract' import { FeedLiquidity } from './feed-liquidity' export function FeedItems(props: { @@ -68,7 +68,10 @@ export function FeedItems(props: { ))} </div> {outcomeType === 'BINARY' && tradingAllowed(contract) && ( - <BetRow contract={contract} className={clsx('mb-2', betRowClassName)} /> + <BetRow + contract={contract as CPMMBinaryContract} + className={clsx('mb-2', betRowClassName)} + /> )} </div> ) diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx new file mode 100644 index 00000000..5a6a67c0 --- /dev/null +++ b/web/components/limit-bets.tsx @@ -0,0 +1,89 @@ +import clsx from 'clsx' +import { LimitBet } from 'common/bet' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' +import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { formatMoney, formatPercent } from 'common/util/format' +import { sortBy } from 'lodash' +import { useState } from 'react' +import { cancelBet } from 'web/lib/firebase/api-call' +import { Col } from './layout/col' +import { LoadingIndicator } from './loading-indicator' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' + +export function LimitBets(props: { + contract: CPMMBinaryContract | PseudoNumericContract + bets: LimitBet[] + className?: string +}) { + const { contract, bets, className } = props + const recentBets = sortBy( + bets, + (bet) => -1 * bet.limitProb, + (bet) => -1 * bet.createdTime + ) + + return ( + <Col + className={clsx(className, 'gap-2 overflow-hidden rounded bg-white py-3')} + > + <div className="px-6 py-3 text-2xl">Your limit bets</div> + <div className="px-4"> + <table className="table-compact table w-full rounded text-gray-500"> + <tbody> + {recentBets.map((bet) => ( + <LimitBet key={bet.id} bet={bet} contract={contract} /> + ))} + </tbody> + </table> + </div> + </Col> + ) +} + +function LimitBet(props: { + contract: CPMMBinaryContract | PseudoNumericContract + bet: LimitBet +}) { + const { contract, bet } = props + const { orderAmount, amount, limitProb, outcome } = bet + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + + const [isCancelling, setIsCancelling] = useState(false) + + const onCancel = () => { + cancelBet({ betId: bet.id }) + setIsCancelling(true) + } + + return ( + <tr> + <td> + <div className="pl-2"> + {isPseudoNumeric ? ( + <PseudoNumericOutcomeLabel outcome={outcome as 'YES' | 'NO'} /> + ) : ( + <BinaryOutcomeLabel outcome={outcome as 'YES' | 'NO'} /> + )} + </div> + </td> + <td>{formatMoney(orderAmount - amount)}</td> + <td> + {isPseudoNumeric + ? getFormattedMappedValue(contract)(limitProb) + : formatPercent(limitProb)} + </td> + <td> + {isCancelling ? ( + <LoadingIndicator /> + ) : ( + <button + className="btn btn-xs btn-outline my-auto normal-case" + onClick={onCancel} + > + Cancel + </button> + )} + </td> + </tr> + ) +} diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index cf111281..98a2aabc 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -96,7 +96,7 @@ export function NumericResolutionPanel(props: { {outcomeMode === 'NUMBER' && ( <BucketInput - contract={contract as any} + contract={contract} isSubmitting={isSubmitting} onBucketChange={(v, o) => (setValue(v), setOutcome(o))} /> diff --git a/web/components/probability-input.tsx b/web/components/probability-input.tsx new file mode 100644 index 00000000..15f73799 --- /dev/null +++ b/web/components/probability-input.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx' +import { Col } from './layout/col' +import { Spacer } from './layout/spacer' + +export function ProbabilityInput(props: { + prob: number | undefined + onChange: (newProb: number | undefined) => void + disabled?: boolean + className?: string + inputClassName?: string +}) { + const { prob, onChange, disabled, className, inputClassName } = props + + const onProbChange = (str: string) => { + let prob = parseInt(str.replace(/\D/g, '')) + const isInvalid = !str || isNaN(prob) + if (prob.toString().length > 2) { + if (prob === 100) prob = 99 + else if (prob < 1) prob = 1 + else prob = +prob.toString().slice(-2) + } + onChange(isInvalid ? undefined : prob) + } + + return ( + <Col className={className}> + <label className="input-group"> + <input + className={clsx( + 'input input-bordered max-w-[200px] text-lg', + inputClassName + )} + type="number" + max={99} + min={1} + pattern="[0-9]*" + inputMode="numeric" + placeholder="0" + maxLength={2} + value={prob ?? ''} + disabled={disabled} + onChange={(e) => onProbChange(e.target.value)} + /> + <span className="bg-gray-200 text-sm">%</span> + </label> + <Spacer h={4} /> + </Col> + ) +} diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx index a8cb2851..4c12c35c 100644 --- a/web/components/sell-row.tsx +++ b/web/components/sell-row.tsx @@ -6,7 +6,7 @@ import { Row } from './layout/row' import { formatWithCommas } from 'common/util/format' import { OutcomeLabel } from './outcome-label' import { useUserContractBets } from 'web/hooks/use-user-bets' -import { useSaveShares } from './use-save-shares' +import { useSaveBinaryShares } from './use-save-binary-shares' import { SellSharesModal } from './sell-modal' export function SellRow(props: { @@ -20,16 +20,7 @@ export function SellRow(props: { const [showSellModal, setShowSellModal] = useState(false) const { mechanism } = contract - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets - ) - const floorShares = yesFloorShares || noFloorShares - const sharesOutcome = yesFloorShares - ? 'YES' - : noFloorShares - ? 'NO' - : undefined + const { sharesOutcome, shares } = useSaveBinaryShares(contract, userBets) if (sharesOutcome && user && mechanism === 'cpmm-1') { return ( @@ -37,7 +28,7 @@ export function SellRow(props: { <Col className={className}> <Row className="items-center justify-between gap-2 "> <div> - You have {formatWithCommas(floorShares)}{' '} + You have {formatWithCommas(shares)}{' '} <OutcomeLabel outcome={sharesOutcome} contract={contract} @@ -64,7 +55,7 @@ export function SellRow(props: { contract={contract} user={user} userBets={userBets ?? []} - shares={yesShares || noShares} + shares={shares} sharesOutcome={sharesOutcome} setOpen={setShowSellModal} /> diff --git a/web/components/use-save-binary-shares.ts b/web/components/use-save-binary-shares.ts new file mode 100644 index 00000000..fefa8a55 --- /dev/null +++ b/web/components/use-save-binary-shares.ts @@ -0,0 +1,56 @@ +import { BinaryContract, PseudoNumericContract } from 'common/contract' +import { Bet } from 'common/bet' +import { useEffect, useState } from 'react' +import { partition, sumBy } from 'lodash' +import { safeLocalStorage } from 'web/lib/util/local' + +export const useSaveBinaryShares = ( + contract: BinaryContract | PseudoNumericContract, + userBets: Bet[] | undefined +) => { + const [savedShares, setSavedShares] = useState({ yesShares: 0, noShares: 0 }) + + const [yesBets, noBets] = partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = userBets + ? [sumBy(yesBets, (bet) => bet.shares), sumBy(noBets, (bet) => bet.shares)] + : [savedShares.yesShares, savedShares.noShares] + + useEffect(() => { + const local = safeLocalStorage() + + // Read shares from local storage. + const savedShares = local?.getItem(`${contract.id}-shares`) + if (savedShares) { + setSavedShares(JSON.parse(savedShares)) + } + + if (userBets) { + // Save shares to local storage. + const sharesData = JSON.stringify({ yesShares, noShares }) + local?.setItem(`${contract.id}-shares`, sharesData) + } + }, [contract.id, userBets, noShares, yesShares]) + + const hasYesShares = yesShares >= 1 + const hasNoShares = noShares >= 1 + + const sharesOutcome = hasYesShares + ? ('YES' as const) + : hasNoShares + ? ('NO' as const) + : undefined + const shares = + sharesOutcome === 'YES' ? yesShares : sharesOutcome === 'NO' ? noShares : 0 + + return { + yesShares, + noShares, + shares, + sharesOutcome, + hasYesShares, + hasNoShares, + } +} diff --git a/web/components/use-save-shares.ts b/web/components/use-save-shares.ts deleted file mode 100644 index 494c1f29..00000000 --- a/web/components/use-save-shares.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Contract } from 'common/contract' -import { Bet } from 'common/bet' -import { useEffect, useState } from 'react' -import { partition, sumBy } from 'lodash' -import { safeLocalStorage } from 'web/lib/util/local' - -export const useSaveShares = ( - contract: Contract, - userBets: Bet[] | undefined, - freeResponseAnswerOutcome?: string -) => { - const [savedShares, setSavedShares] = useState< - | { - yesShares: number - noShares: number - yesFloorShares: number - noFloorShares: number - } - | undefined - >() - - // TODO: How do we handle numeric yes / no bets? - maybe bet amounts above vs below the highest peak - const [yesBets, noBets] = partition(userBets ?? [], (bet) => - freeResponseAnswerOutcome - ? bet.outcome === freeResponseAnswerOutcome - : bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - sumBy(yesBets, (bet) => bet.shares), - sumBy(noBets, (bet) => bet.shares), - ] - - const yesFloorShares = Math.round(yesShares) === 0 ? 0 : Math.floor(yesShares) - const noFloorShares = Math.round(noShares) === 0 ? 0 : Math.floor(noShares) - - useEffect(() => { - const local = safeLocalStorage() - // Save yes and no shares to local storage. - const savedShares = local?.getItem(`${contract.id}-shares`) - if (!userBets && savedShares) { - setSavedShares(JSON.parse(savedShares)) - } - - if (userBets) { - const updatedShares = { yesShares, noShares } - local?.setItem(`${contract.id}-shares`, JSON.stringify(updatedShares)) - } - }, [contract.id, userBets, noShares, yesShares]) - - if (userBets) return { yesShares, noShares, yesFloorShares, noFloorShares } - return ( - savedShares ?? { - yesShares: 0, - noShares: 0, - yesFloorShares: 0, - noFloorShares: 0, - } - ) -} diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 5cab16a7..68b296cd 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -4,8 +4,10 @@ import { Bet, listenForBets, listenForRecentBets, + listenForUnfilledBets, withoutAnteBets, } from 'web/lib/firebase/bets' +import { LimitBet } from 'common/bet' export const useBets = (contractId: string) => { const [bets, setBets] = useState<Bet[] | undefined>() @@ -36,3 +38,12 @@ export const useRecentBets = () => { useEffect(() => listenForRecentBets(setRecentBets), []) return recentBets } + +export const useUnfilledBets = (contractId: string) => { + const [unfilledBets, setUnfilledBets] = useState<LimitBet[] | undefined>() + useEffect( + () => listenForUnfilledBets(contractId, setUnfilledBets), + [contractId] + ) + return unfilledBets +} diff --git a/web/hooks/use-focus.ts b/web/hooks/use-focus.ts index a71a0292..f41f46a7 100644 --- a/web/hooks/use-focus.ts +++ b/web/hooks/use-focus.ts @@ -1,11 +1,12 @@ import { useRef } from 'react' +import { useEvent } from './use-event' // Focus helper from https://stackoverflow.com/a/54159564/1222351 export function useFocus(): [React.RefObject<HTMLElement>, () => void] { const htmlElRef = useRef<HTMLElement>(null) - const setFocus = () => { + const setFocus = useEvent(() => { htmlElRef.current && htmlElRef.current.focus() - } + }) return [htmlElRef, setFocus] } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 7882d9ba..94da9f09 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -82,6 +82,10 @@ export function placeBet(params: any) { return call(getFunctionUrl('placebet'), 'POST', params) } +export function cancelBet(params: { betId: string }) { + return call(getFunctionUrl('cancelbet'), 'POST', params) +} + export function sellShares(params: any) { return call(getFunctionUrl('sellshares'), 'POST', params) } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 6fc29d24..ef0ab55d 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -15,7 +15,7 @@ import { import { uniq } from 'lodash' import { db } from './init' -import { Bet } from 'common/bet' +import { Bet, LimitBet } from 'common/bet' import { Contract } from 'common/contract' import { getValues, listenForValues } from './utils' import { getContractFromId } from './contracts' @@ -166,6 +166,21 @@ export function listenForUserContractBets( }) } +export function listenForUnfilledBets( + contractId: string, + setBets: (bets: LimitBet[]) => void +) { + const betsQuery = query( + collection(db, 'contracts', contractId, 'bets'), + where('isFilled', '==', false), + where('isCancelled', '==', false) + ) + return listenForValues<LimitBet>(betsQuery, (bets) => { + bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) + setBets(bets) + }) +} + export function withoutAnteBets(contract: Contract, bets?: Bet[]) { const { createdTime } = contract diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index e33c116e..e8b290f3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -39,6 +39,7 @@ import { FeedBet } from 'web/components/feed/feed-bets' import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' +import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' @@ -127,6 +128,7 @@ export function ContractPageContent( const tips = useTipTxns({ contractId: contract.id }) const user = useUser() + const { width, height } = useWindowSize() const [showConfetti, setShowConfetti] = useState(false) @@ -169,7 +171,10 @@ export function ContractPageContent( (isNumeric ? ( <NumericBetPanel className="hidden xl:flex" contract={contract} /> ) : ( - <BetPanel className="hidden xl:flex" contract={contract} /> + <BetPanel + className="hidden xl:flex" + contract={contract as CPMMBinaryContract} + /> ))} {allowResolve && (isNumeric || isPseudoNumeric ? ( diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 93439be7..dc8cb51d 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -1,5 +1,5 @@ import { Bet } from 'common/bet' -import { Contract } from 'common/contract' +import { Contract, CPMMBinaryContract } from 'common/contract' import { DOMAIN } from 'common/envs/constants' import { AnswersGraph } from 'web/components/answers/answers-graph' import BetRow from 'web/components/bet-row' @@ -112,7 +112,10 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( <Row className="items-center gap-4"> - <BetRow contract={contract} betPanelClassName="scale-75" /> + <BetRow + contract={contract as CPMMBinaryContract} + betPanelClassName="scale-75" + /> <BinaryResolutionOrChance contract={contract} /> </Row> )} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 3a8e4bc0..39cc2017 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -795,6 +795,8 @@ function getSourceIdForLinkComponent( return sourceId case 'contract': return '' + case 'bet': + return '' default: return sourceId } @@ -861,8 +863,16 @@ function NotificationTextLabel(props: { {'+' + formatMoney(parseInt(sourceText))} </span> ) + } else if (sourceType === 'bet' && sourceText) { + return ( + <> + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span>{' '} + <span>of your limit bet was filled</span> + </> + ) } - // return default text return ( <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> <Linkify text={defaultText} /> @@ -913,6 +923,9 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break + case 'bet': + reasonText = 'bet against you' + break default: reasonText = '' }