🧾 Limit orders! (#495)

* 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 <iansphilips@gmail.com>
Co-authored-by: ahalekelly <ahalekelly@gmail.com>
Co-authored-by: mantikoros <sgrugett@gmail.com>
Co-authored-by: Ben Congdon <ben@congdon.dev>
Co-authored-by: Austin Chen <akrolsmir@gmail.com>
This commit is contained in:
James Grugett 2022-07-10 13:05:44 -05:00 committed by GitHub
parent fc06b03af8
commit 80ae551ca9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1209 additions and 522 deletions

View File

@ -4,6 +4,7 @@ export type Bet = {
id: string id: string
userId: string userId: string
contractId: string contractId: string
createdTime: number
amount: number // bet size; negative if SELL bet amount: number // bet size; negative if SELL bet
loanAmount?: number loanAmount?: number
@ -25,9 +26,7 @@ export type Bet = {
isAnte?: boolean isAnte?: boolean
isLiquidityProvision?: boolean isLiquidityProvision?: boolean
isRedemption?: boolean isRedemption?: boolean
} & Partial<LimitProps>
createdTime: number
}
export type NumericBet = Bet & { export type NumericBet = Bet & {
value: number value: number
@ -35,4 +34,29 @@ export type NumericBet = Bet & {
allBetAmounts: { [outcome: string]: number } 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 export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -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 { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet'
import { binarySearch } from './util/algos'
import { addObjects } from './util/object' import { addObjects } from './util/object'
export type CpmmState = {
pool: { [outcome: string]: number }
p: number
}
export function getCpmmProbability( export function getCpmmProbability(
pool: { [outcome: string]: number }, pool: { [outcome: string]: number },
p: number p: number
@ -14,11 +21,11 @@ export function getCpmmProbability(
} }
export function getCpmmProbabilityAfterBetBeforeFees( export function getCpmmProbabilityAfterBetBeforeFees(
contract: CPMMContract, state: CpmmState,
outcome: string, outcome: string,
bet: number bet: number
) { ) {
const { pool, p } = contract const { pool, p } = state
const shares = calculateCpmmShares(pool, p, bet, outcome) const shares = calculateCpmmShares(pool, p, bet, outcome)
const { YES: y, NO: n } = pool const { YES: y, NO: n } = pool
@ -31,12 +38,12 @@ export function getCpmmProbabilityAfterBetBeforeFees(
} }
export function getCpmmOutcomeProbabilityAfterBet( export function getCpmmOutcomeProbabilityAfterBet(
contract: CPMMContract, state: CpmmState,
outcome: string, outcome: string,
bet: number bet: number
) { ) {
const { newPool } = calculateCpmmPurchase(contract, bet, outcome) const { newPool } = calculateCpmmPurchase(state, bet, outcome)
const p = getCpmmProbability(newPool, contract.p) const p = getCpmmProbability(newPool, state.p)
return outcome === 'NO' ? 1 - p : p return outcome === 'NO' ? 1 - p : p
} }
@ -58,12 +65,8 @@ function calculateCpmmShares(
: n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p)) : n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p))
} }
export function getCpmmFees( export function getCpmmFees(state: CpmmState, bet: number, outcome: string) {
contract: CPMMContract, const prob = getCpmmProbabilityAfterBetBeforeFees(state, outcome, bet)
bet: number,
outcome: string
) {
const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
const betP = outcome === 'YES' ? 1 - prob : prob const betP = outcome === 'YES' ? 1 - prob : prob
const liquidityFee = LIQUIDITY_FEE * betP * bet const liquidityFee = LIQUIDITY_FEE * betP * bet
@ -78,23 +81,23 @@ export function getCpmmFees(
} }
export function calculateCpmmSharesAfterFee( export function calculateCpmmSharesAfterFee(
contract: CPMMContract, state: CpmmState,
bet: number, bet: number,
outcome: string outcome: string
) { ) {
const { pool, p } = contract const { pool, p } = state
const { remainingBet } = getCpmmFees(contract, bet, outcome) const { remainingBet } = getCpmmFees(state, bet, outcome)
return calculateCpmmShares(pool, p, remainingBet, outcome) return calculateCpmmShares(pool, p, remainingBet, outcome)
} }
export function calculateCpmmPurchase( export function calculateCpmmPurchase(
contract: CPMMContract, state: CpmmState,
bet: number, bet: number,
outcome: string outcome: string
) { ) {
const { pool, p } = contract const { pool, p } = state
const { remainingBet, fees } = getCpmmFees(contract, bet, outcome) const { remainingBet, fees } = getCpmmFees(state, bet, outcome)
const shares = calculateCpmmShares(pool, p, remainingBet, outcome) const shares = calculateCpmmShares(pool, p, remainingBet, outcome)
const { YES: y, NO: n } = pool const { YES: y, NO: n } = pool
@ -113,117 +116,111 @@ export function calculateCpmmPurchase(
return { shares, newPool, newP, fees } return { shares, newPool, newP, fees }
} }
function computeK(y: number, n: number, p: number) { // Note: there might be a closed form solution for this.
return y ** p * n ** (1 - p) // If so, feel free to switch out this implementation.
} export function calculateCpmmAmountToProb(
state: CpmmState,
function sellSharesK( prob: number,
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,
outcome: 'YES' | 'NO' outcome: 'YES' | 'NO'
) { ) {
const { pool, p } = contract if (outcome === 'NO') prob = 1 - prob
// Find bet amount that preserves k after selling shares. // First, find an upper bound that leads to a more extreme probability than prob.
const k = computeK(pool.YES, pool.NO, p) let maxGuess = 10
const otherPool = outcome === 'YES' ? pool.NO : pool.YES 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. // Then, binary search for the amount that gets closest to prob.
// This is because 1. the max value per share is M$ 1, const amount = binarySearch(0, maxGuess, (amount) => {
// and 2. The other pool cannot go negative and the sale value is subtracted from it. const newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, amount)
// (Without this, there are multiple solutions for the same k.) return newProb - prob
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. return amount
if (mid === lowAmount || mid === highAmount) break }
kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid) function calculateAmountToBuyShares(
if (kGuess < k) { state: CpmmState,
highAmount = mid shares: number,
} else { outcome: 'YES' | 'NO',
lowAmount = mid unfilledBets: LimitBet[]
} ) {
} // Search for amount between bounds (0, shares).
return mid // 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( export function calculateCpmmSale(
contract: CPMMContract, state: CpmmState,
shares: number, shares: number,
outcome: string outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) { ) {
if (Math.round(shares) < 0) { if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares') throw new Error('Cannot sell non-positive shares')
} }
const rawSaleValue = calculateCpmmShareValue( const oppositeOutcome = outcome === 'YES' ? 'NO' : 'YES'
contract, const buyAmount = calculateAmountToBuyShares(
state,
shares, shares,
outcome as 'YES' | 'NO' oppositeOutcome,
unfilledBets
) )
const { fees, remainingBet: saleValue } = getCpmmFees( const { cpmmState, makers, takers, totalFees } = computeFills(
contract, oppositeOutcome,
rawSaleValue, buyAmount,
outcome === 'YES' ? 'NO' : 'YES' state,
undefined,
unfilledBets
) )
const { pool } = contract // Transform buys of opposite outcome into sells.
const { YES: y, NO: n } = pool 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] = return {
outcome === 'YES' saleValue,
? [y + shares - saleValue + fee, n - saleValue + fee] cpmmState,
: [y - saleValue + fee, n + shares - saleValue + fee] fees: totalFees,
makers,
if (newY < 0 || newN < 0) { takers: saleTakers,
console.log('calculateCpmmSale', {
newY,
newN,
y,
n,
shares,
saleValue,
fee,
outcome,
})
throw new Error('Cannot sell more than in pool')
} }
const postBetPool = { YES: newY, NO: newN }
const { newPool, newP } = addCpmmLiquidity(postBetPool, contract.p, fee)
return { saleValue, newPool, newP, fees }
} }
export function getCpmmProbabilityAfterSale( export function getCpmmProbabilityAfterSale(
contract: CPMMContract, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO' outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) { ) {
const { newPool } = calculateCpmmSale(contract, shares, outcome) const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
return getCpmmProbability(newPool, contract.p) return getCpmmProbability(cpmmState.pool, cpmmState.p)
} }
export function getCpmmLiquidity( export function getCpmmLiquidity(
@ -267,11 +264,11 @@ const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
} }
export function getCpmmLiquidityPoolWeights( export function getCpmmLiquidityPoolWeights(
contract: CPMMContract, state: CpmmState,
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[],
excludeAntes: boolean excludeAntes: boolean
) { ) {
const calcLiqudity = calculateLiquidityDelta(contract.p) const calcLiqudity = calculateLiquidityDelta(state.p)
const liquidityShares = liquidities.map(calcLiqudity) const liquidityShares = liquidities.map(calcLiqudity)
const shareSum = sum(liquidityShares) const shareSum = sum(liquidityShares)
@ -293,16 +290,12 @@ export function getCpmmLiquidityPoolWeights(
export function getUserLiquidityShares( export function getUserLiquidityShares(
userId: string, userId: string,
contract: CPMMContract, state: CpmmState,
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[],
excludeAntes: boolean excludeAntes: boolean
) { ) {
const weights = getCpmmLiquidityPoolWeights( const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
contract,
liquidities,
excludeAntes
)
const userWeight = weights[userId] ?? 0 const userWeight = weights[userId] ?? 0
return mapValues(contract.pool, (shares) => userWeight * shares) return mapValues(state.pool, (shares) => userWeight * shares)
} }

View File

@ -1,5 +1,5 @@
import { maxBy } from 'lodash' import { maxBy } from 'lodash'
import { Bet } from './bet' import { Bet, LimitBet } from './bet'
import { import {
calculateCpmmSale, calculateCpmmSale,
getCpmmProbability, getCpmmProbability,
@ -24,6 +24,7 @@ import {
FreeResponseContract, FreeResponseContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
import { floatingEqual } from './util/math'
export function getProbability( export function getProbability(
contract: BinaryContract | PseudoNumericContract contract: BinaryContract | PseudoNumericContract
@ -73,11 +74,20 @@ export function calculateShares(
: calculateDpmShares(contract.totalShares, bet, betChoice) : 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' && return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' || (contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC') 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) : calculateDpmSaleAmount(contract, bet)
} }
@ -90,10 +100,16 @@ export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
export function getProbabilityAfterSale( export function getProbabilityAfterSale(
contract: Contract, contract: Contract,
outcome: string, outcome: string,
shares: number shares: number,
unfilledBets: LimitBet[]
) { ) {
return contract.mechanism === 'cpmm-1' 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) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
} }
@ -157,7 +173,9 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const profit = payout + saleValue + redeemed - totalInvested const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100 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 { return {
invested: Math.max(0, currentInvested), invested: Math.max(0, currentInvested),

View File

@ -34,5 +34,9 @@ export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const CORS_ORIGIN_MANIFOLD = new RegExp( export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' '^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 // Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/

View File

@ -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 { import {
calculateDpmShares, calculateDpmShares,
getDpmProbability, getDpmProbability,
@ -8,7 +8,12 @@ import {
getNumericBets, getNumericBets,
calculateNumericDpmShares, calculateNumericDpmShares,
} from './calculate-dpm' } from './calculate-dpm'
import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' import {
calculateCpmmAmountToProb,
calculateCpmmPurchase,
CpmmState,
getCpmmProbability,
} from './calculate-cpmm'
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
@ -17,8 +22,13 @@ import {
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
import { noFees } from './fees' import { noFees } from './fees'
import { addObjects } from './util/object' import { addObjects, removeUndefinedProps } from './util/object'
import { NUMERIC_FIXED_VAR } from './numeric-constants' import { NUMERIC_FIXED_VAR } from './numeric-constants'
import {
floatingEqual,
floatingGreaterEqual,
floatingLesserEqual,
} from './util/math'
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
export type BetInfo = { export type BetInfo = {
@ -30,38 +40,203 @@ export type BetInfo = {
newP?: number newP?: number
} }
export const getNewBinaryCpmmBetInfo = ( const computeFill = (
outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: CPMMBinaryContract | PseudoNumericContract, outcome: 'YES' | 'NO',
loanAmount: number limitProb: number | undefined,
cpmmState: CpmmState,
matchedBet: LimitBet | undefined
) => { ) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase( const prob = getCpmmProbability(cpmmState.pool, cpmmState.p)
contract,
amount,
outcome
)
const { pool, p, totalLiquidity } = contract if (
const probBefore = getCpmmProbability(pool, p) limitProb !== undefined &&
const probAfter = getCpmmProbability(newPool, newP) (outcome === 'YES'
? floatingGreaterEqual(prob, limitProb) &&
const newBet: CandidateBet = { (matchedBet?.limitProb ?? 1) > limitProb
contractId: contract.id, : floatingLesserEqual(prob, limitProb) &&
amount, (matchedBet?.limitProb ?? 0) < limitProb)
shares, ) {
outcome, // No fill.
fees, return undefined
loanAmount,
probBefore,
probAfter,
createdTime: Date.now(),
} }
const { liquidityFee } = fees const timestamp = Date.now()
const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee
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 = ( export const getNewBinaryDpmBetInfo = (

View File

@ -62,3 +62,4 @@ export type notification_reason_types =
| 'unique_bettors_on_your_contract' | 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of' | 'on_group_you_are_member_of'
| 'tip_received' | 'tip_received'
| 'bet_fill'

View File

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

22
common/util/algos.ts Normal file
View File

@ -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
}

View File

@ -34,3 +34,17 @@ export function median(xs: number[]) {
export function average(xs: number[]) { export function average(xs: number[]) {
return sum(xs) / xs.length 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
}

View File

@ -8,6 +8,7 @@ import { PrivateUser } from '../../common/user'
import { import {
CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST, CORS_ORIGIN_LOCALHOST,
CORS_ORIGIN_VERCEL,
} from '../../common/envs/constants' } from '../../common/envs/constants'
type Output = Record<string, unknown> type Output = Record<string, unknown>
@ -118,7 +119,7 @@ const DEFAULT_OPTS = {
concurrency: 100, concurrency: 100,
memory: '2GiB', memory: '2GiB',
cpu: 1, 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) => { export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {

View File

@ -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()

View File

@ -10,7 +10,7 @@ import { Contract } from '../../common/contract'
import { getUserByUsername, getValues } from './utils' import { getUserByUsername, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
@ -382,3 +382,37 @@ export const createTipNotification = async (
} }
return await notificationRef.set(removeUndefinedProps(notification)) 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))
}

View File

@ -31,6 +31,7 @@ export * from './transact'
export * from './change-user-info' export * from './change-user-info'
export * from './create-answer' export * from './create-answer'
export * from './place-bet' export * from './place-bet'
export * from './cancel-bet'
export * from './sell-bet' export * from './sell-bet'
export * from './sell-shares' export * from './sell-shares'
export * from './claim-manalink' export * from './claim-manalink'

View File

@ -1,7 +1,11 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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() const firestore = admin.firestore()
@ -11,6 +15,8 @@ export const onCreateBet = functions.firestore
const { contractId } = context.params as { const { contractId } = context.params as {
contractId: string contractId: string
} }
const { eventId } = context
const bet = change.data() as Bet const bet = change.data() as Bet
const lastBetTime = bet.createdTime const lastBetTime = bet.createdTime
@ -18,4 +24,47 @@ export const onCreateBet = functions.firestore
.collection('contracts') .collection('contracts')
.doc(contractId) .doc(contractId)
.update({ lastBetTime, lastUpdatedTime: Date.now() }) .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<LimitBet>(
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
)
})
)
}

View File

@ -5,6 +5,8 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onUpdateUser = functions.firestore export const onUpdateUser = functions.firestore
@ -17,6 +19,10 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) { if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId) await handleUserUpdatedReferral(user, eventId)
} }
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
}) })
async function handleUserUpdatedReferral(user: User, eventId: string) { 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<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -1,17 +1,25 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' 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 { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { import {
BetInfo, BetInfo,
getNewBinaryCpmmBetInfo, getBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo,
getNewMultiBetInfo, getNewMultiBetInfo,
getNumericBetsInfo, getNumericBetsInfo,
} from '../../common/new-bet' } from '../../common/new-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
@ -22,6 +30,7 @@ const bodySchema = z.object({
const binarySchema = z.object({ const binarySchema = z.object({
outcome: z.enum(['YES', 'NO']), outcome: z.enum(['YES', 'NO']),
limitProb: z.number().gte(0.001).lte(0.999).optional(),
}) })
const freeResponseSchema = z.object({ const freeResponseSchema = z.object({
@ -63,16 +72,30 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalBets, newTotalBets,
newTotalLiquidity, newTotalLiquidity,
newP, newP,
} = await (async (): Promise<BetInfo> => { makers,
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { } = await (async (): Promise<
const { outcome } = validate(binarySchema, req.body) BetInfo & {
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) makers?: maker[]
} else if ( }
(outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && > => {
if (
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
mechanism == 'cpmm-1' mechanism == 'cpmm-1'
) { ) {
const { outcome } = validate(binarySchema, req.body) const { outcome, limitProb } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
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') { } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
const { outcome } = validate(freeResponseSchema, req.body) const { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome) 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.') throw new APIError(400, 'Bet too large for current liquidity pool.')
} }
const newBalance = user.balance - amount - loanAmount
const betDoc = contractDoc.collection('bets').doc() const betDoc = contractDoc.collection('bets').doc()
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
log('Created new bet document.') 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.') log('Updated user balance.')
trans.update( trans.update(
contractDoc, contractDoc,
@ -112,7 +139,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
totalBets: newTotalBets, totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity, totalLiquidity: newTotalLiquidity,
collectedFees: addObjects(newBet.fees, collectedFees), collectedFees: addObjects(newBet.fees, collectedFees),
volume: volume + amount, volume: volume + newBet.amount,
}) })
) )
log('Updated contract properties.') log('Updated contract properties.')
@ -127,3 +154,54 @@ export const placebet = newEndpoint({}, async (req, auth) => {
}) })
const firestore = admin.firestore() 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.
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 { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues } from './utils' import { getValues } from './utils'
import { Bet } from '../../common/bet' 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({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -46,14 +49,22 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares) 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.`) throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( const soldShares = Math.min(shares, maxShares)
shares,
const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
outcome, outcome,
contract, contract,
prevLoanAmount prevLoanAmount,
unfilledBets
) )
if ( if (
@ -65,11 +76,17 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
} }
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() 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 }) updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet })
transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount),
})
transaction.create(newBetDoc, {
id: newBetDoc.id,
userId: user.id,
...newBet,
})
transaction.update( transaction.update(
contractDoc, contractDoc,
removeUndefinedProps({ removeUndefinedProps({

View File

@ -1,13 +1,10 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { partition, sumBy } from 'lodash' import { partition, sumBy } from 'lodash'
import { SwitchHorizontalIcon } from '@heroicons/react/solid'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
BinaryContract,
CPMMBinaryContract,
PseudoNumericContract,
} from 'common/contract'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -18,20 +15,16 @@ import {
formatPercent, formatPercent,
formatWithCommas, formatWithCommas,
} from 'common/util/format' } from 'common/util/format'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { Title } from './title' import { Title } from './title'
import { User } from 'web/lib/firebase/users' 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 { APIError, placeBet } from 'web/lib/firebase/api-call'
import { sellShares } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call'
import { AmountInput, BuyAmountInput } from './amount-input' import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { BinaryOutcomeLabel } from './outcome-label'
import { import { getProbability } from 'common/calculate'
calculatePayoutAfterCorrectBet,
calculateShares,
getProbability,
getOutcomeProbabilityAfterBet,
} from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus' import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { import {
@ -39,178 +32,153 @@ import {
getCpmmProbability, getCpmmProbability,
getCpmmFees, getCpmmFees,
} from 'common/calculate-cpmm' } from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric' import {
getFormattedMappedValue,
getPseudoProbability,
} from 'common/pseudo-numeric'
import { SellRow } from './sell-row' 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 { SignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device' import { isIOS } from 'web/lib/util/device'
import { ProbabilityInput } from './probability-input'
import { track } from 'web/lib/service/analytics' 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: { export function BetPanel(props: {
contract: BinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
}) { }) {
const { contract, className } = props const { contract, className } = props
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) const unfilledBets = useUnfilledBets(contract.id) ?? []
const sharesOutcome = yesFloorShares const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
? 'YES' const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
: noFloorShares
? 'NO' const [isLimitOrder, setIsLimitOrder] = useState(false)
: undefined
return ( return (
<Col className={className}> <Col className={className}>
<SellRow <SellRow
contract={contract} contract={contract}
user={user} user={user}
className={'rounded-t-md bg-gray-100 px-6 py-6'} className={'rounded-t-md bg-gray-100 px-4 py-5'}
/> />
<Col <Col
className={clsx( className={clsx(
'rounded-b-md bg-white px-8 py-6', 'relative rounded-b-md bg-white px-8 py-6',
!sharesOutcome && 'rounded-t-md', !sharesOutcome && 'rounded-t-md',
className className
)} )}
> >
<div className="mb-6 text-2xl">Place your bet</div> <Row className="align-center justify-between">
{/* <Title className={clsx('!mt-0 text-neutral')} text="Place a trade" /> */} <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 /> <SignUpPrompt />
</Col> </Col>
{yourUnfilledBets.length > 0 && (
<LimitBets
className="mt-4"
contract={contract}
bets={yourUnfilledBets}
/>
)}
</Col> </Col>
) )
} }
export function BetPanelSwitcher(props: { export function SimpleBetPanel(props: {
contract: BinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
title?: string // Set if BetPanel is on a feed modal
selected?: 'YES' | 'NO' selected?: 'YES' | 'NO'
onBetSuccess?: () => void onBetSuccess?: () => void
}) { }) {
const { contract, className, title, selected, onBetSuccess } = props const { contract, className, selected, onBetSuccess } = props
const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const [isLimitOrder, setIsLimitOrder] = useState(false)
const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY') const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
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])
return ( return (
<Col className={className}> <Col className={className}>
{sharesOutcome && mechanism === 'cpmm-1' && ( <Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}>
<Col className="rounded-t-md bg-gray-100 px-6 py-6"> <Row className="justify-between">
<Row className="items-center justify-between gap-2"> <Title
<div> className={clsx('!mt-0')}
You have {formatWithCommas(floorShares)}{' '} text={isLimitOrder ? 'Limit bet' : 'Place a trade'}
{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}
/> />
)}
<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 /> <SignUpPrompt />
</Col> </Col>
{yourUnfilledBets.length > 0 && (
<LimitBets
className="mt-4"
contract={contract}
bets={yourUnfilledBets}
/>
)}
</Col> </Col>
) )
} }
function BuyPanel(props: { function BuyPanel(props: {
contract: BinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[]
isLimitOrder?: boolean
selected?: 'YES' | 'NO' selected?: 'YES' | 'NO'
onBuySuccess?: () => void 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 isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [limitProb, setLimitProb] = useState<number | undefined>(
Math.round(100 * initialProb)
)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false)
@ -240,15 +208,22 @@ function BuyPanel(props: {
async function submitBet() { async function submitBet() {
if (!user || !betAmount) return if (!user || !betAmount) return
if (isLimitOrder && limitProb === undefined) return
const limitProbScaled =
isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined
setError(undefined) setError(undefined)
setIsSubmitting(true) setIsSubmitting(true)
placeBet({ placeBet(
amount: betAmount, removeUndefinedProps({
outcome: betChoice, amount: betAmount,
contractId: contract.id, outcome: betChoice,
}) contractId: contract.id,
limitProb: limitProbScaled,
})
)
.then((r) => { .then((r) => {
console.log('placed bet. Result:', r) console.log('placed bet. Result:', r)
setIsSubmitting(false) setIsSubmitting(false)
@ -278,42 +253,31 @@ function BuyPanel(props: {
const betDisabled = isSubmitting || !betAmount || error 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, contract,
betChoice || 'YES', isLimitOrder ? limitProbFrac : undefined,
betAmount ?? 0 unfilledBets as LimitBet[]
) )
const resultProb = betChoice === 'NO' ? 1 - outcomeProb : outcomeProb
const shares = calculateShares(contract, betAmount ?? 0, betChoice || 'YES') const resultProb = getCpmmProbability(newPool, newP)
const remainingMatched = isLimitOrder
const currentPayout = betAmount ? ((newBet.orderAmount ?? 0) - newBet.amount) /
? calculatePayoutAfterCorrectBet(contract, { (betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac)
outcome: betChoice,
amount: betAmount,
shares,
} as Bet)
: 0 : 0
const currentPayout = newBet.shares + remainingMatched
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn) const currentReturnPercent = formatPercent(currentReturn)
const cpmmFees = const cpmmFees = getCpmmFees(
contract.mechanism === 'cpmm-1' && contract,
getCpmmFees(contract, betAmount ?? 0, betChoice ?? 'YES').totalFees betAmount ?? 0,
betChoice ?? 'YES'
const dpmTooltip = ).totalFees
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 format = getFormattedMappedValue(contract) const format = getFormattedMappedValue(contract)
@ -336,29 +300,62 @@ function BuyPanel(props: {
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} 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"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> {!isLimitOrder && (
<div className="text-gray-500"> <Row className="items-center justify-between text-sm">
{isPseudoNumeric ? 'Estimated value' : 'Probability'} <div className="text-gray-500">
</div> {isPseudoNumeric ? 'Estimated value' : 'Probability'}
<div> </div>
{format(initialProb)} <div>
<span className="mx-2"></span> {format(initialProb)}
{format(resultProb)} <span className="mx-2"></span>
</div> {format(resultProb)}
</Row> </div>
</Row>
)}
<Row className="items-center justify-between gap-2 text-sm"> <Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div> <div>
{contract.mechanism === 'dpm-2' ? ( {isPseudoNumeric ? (
<>
Estimated
<br /> payout if{' '}
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
</>
) : isPseudoNumeric ? (
'Max payout' 'Max payout'
) : ( ) : (
<> <>
@ -366,14 +363,9 @@ function BuyPanel(props: {
</> </>
)} )}
</div> </div>
<InfoTooltip
{cpmmFees !== false && ( text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`}
<InfoTooltip />
text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`}
/>
)}
{dpmTooltip && <InfoTooltip text={dpmTooltip} />}
</Row> </Row>
<div> <div>
<span className="mr-2 whitespace-nowrap"> <span className="mr-2 whitespace-nowrap">
@ -424,19 +416,21 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const betDisabled = isSubmitting || !amount || error 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() { async function submitSell() {
if (!user || !amount) return if (!user || !amount) return
setError(undefined) setError(undefined)
setIsSubmitting(true) setIsSubmitting(true)
// Sell all shares if remaining shares would be < 1
const sellAmount = amount === Math.floor(shares) ? shares : amount
await sellShares({ await sellShares({
shares: sellAmount, shares: sellQuantity,
outcome: sharesOutcome, outcome: sharesOutcome,
contractId: contract.id, contractId: contract.id,
}) })
@ -461,18 +455,19 @@ export function SellPanel(props: {
outcomeType: contract.outcomeType, outcomeType: contract.outcomeType,
slug: contract.slug, slug: contract.slug,
contractId: contract.id, contractId: contract.id,
shares: sellAmount, shares: sellQuantity,
outcome: sharesOutcome, outcome: sharesOutcome,
}) })
} }
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const { newPool } = calculateCpmmSale( const { cpmmState, saleValue } = calculateCpmmSale(
contract, contract,
Math.min(amount ?? 0, shares), sellQuantity ?? 0,
sharesOutcome 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 openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = partition( const [yesBets, noBets] = partition(
@ -484,17 +479,8 @@ export function SellPanel(props: {
sumBy(noBets, (bet) => bet.shares), sumBy(noBets, (bet) => bet.shares),
] ]
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
const ownedShares = Math.round(yesShares) || Math.round(noShares) 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) => { const onAmountChange = (amount: number | undefined) => {
setAmount(amount) setAmount(amount)

View File

@ -1,18 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { BetPanelSwitcher } from './bet-panel' import { SimpleBetPanel } from './bet-panel'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { BinaryContract, PseudoNumericContract } from 'common/contract' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { SellButton } from './sell-button' import { SellButton } from './sell-button'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets' 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. // Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: { export default function BetRow(props: {
contract: BinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
btnClassName?: string btnClassName?: string
betPanelClassName?: string betPanelClassName?: string
@ -24,10 +24,8 @@ export default function BetRow(props: {
) )
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( const { yesShares, noShares, hasYesShares, hasNoShares } =
contract, useSaveBinaryShares(contract, userBets)
userBets
)
return ( return (
<> <>
@ -40,7 +38,7 @@ export default function BetRow(props: {
setBetChoice(choice) setBetChoice(choice)
}} }}
replaceNoButton={ replaceNoButton={
yesFloorShares > 0 ? ( hasYesShares ? (
<SellButton <SellButton
panelClassName={betPanelClassName} panelClassName={betPanelClassName}
contract={contract} contract={contract}
@ -51,7 +49,7 @@ export default function BetRow(props: {
) : undefined ) : undefined
} }
replaceYesButton={ replaceYesButton={
noFloorShares > 0 ? ( hasNoShares ? (
<SellButton <SellButton
panelClassName={betPanelClassName} panelClassName={betPanelClassName}
contract={contract} contract={contract}
@ -63,10 +61,9 @@ export default function BetRow(props: {
} }
/> />
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>
<BetPanelSwitcher <SimpleBetPanel
className={betPanelClassName} className={betPanelClassName}
contract={contract} contract={contract}
title={contract.question}
selected={betChoice} selected={betChoice}
onBetSuccess={() => setOpen(false)} onBetSuccess={() => setOpen(false)}
/> />

View File

@ -44,6 +44,9 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { SellSharesModal } from './sell-modal' 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 BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all'
@ -390,6 +393,12 @@ export function BetsSummary(props: {
const [showSellModal, setShowSellModal] = useState(false) const [showSellModal, setShowSellModal] = useState(false)
const user = useUser() const user = useUser()
const sharesOutcome = floatingEqual(totalShares.YES, 0)
? floatingEqual(totalShares.NO, 0)
? undefined
: 'NO'
: 'YES'
return ( return (
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}> <Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
<Row className="flex-wrap gap-4 sm:gap-6"> <Row className="flex-wrap gap-4 sm:gap-6">
@ -469,6 +478,7 @@ export function BetsSummary(props: {
!isClosed && !isClosed &&
!resolution && !resolution &&
hasShares && hasShares &&
sharesOutcome &&
user && ( user && (
<> <>
<button <button
@ -482,8 +492,8 @@ export function BetsSummary(props: {
contract={contract} contract={contract}
user={user} user={user}
userBets={bets} userBets={bets}
shares={totalShares.YES || totalShares.NO} shares={totalShares[sharesOutcome]}
sharesOutcome={totalShares.YES ? 'YES' : 'NO'} sharesOutcome={sharesOutcome}
setOpen={setShowSellModal} setOpen={setShowSellModal}
/> />
)} )}
@ -505,7 +515,7 @@ export function ContractBetsTable(props: {
const { contract, className, isYourBets } = props const { contract, className, isYourBets } = props
const bets = sortBy( const bets = sortBy(
props.bets.filter((b) => !b.isAnte), props.bets.filter((b) => !b.isAnte && b.amount !== 0),
(bet) => bet.createdTime (bet) => bet.createdTime
).reverse() ).reverse()
@ -531,6 +541,8 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
return ( return (
<div className={clsx('overflow-x-auto', className)}> <div className={clsx('overflow-x-auto', className)}>
{amountRedeemed > 0 && ( {amountRedeemed > 0 && (
@ -577,6 +589,7 @@ export function ContractBetsTable(props: {
saleBet={salesDict[bet.id]} saleBet={salesDict[bet.id]}
contract={contract} contract={contract}
isYourBet={isYourBets} isYourBet={isYourBets}
unfilledBets={unfilledBets}
/> />
))} ))}
</tbody> </tbody>
@ -590,8 +603,9 @@ function BetRow(props: {
contract: Contract contract: Contract
saleBet?: Bet saleBet?: Bet
isYourBet: boolean isYourBet: boolean
unfilledBets: LimitBet[]
}) { }) {
const { bet, saleBet, contract, isYourBet } = props const { bet, saleBet, contract, isYourBet, unfilledBets } = props
const { const {
amount, amount,
outcome, outcome,
@ -621,7 +635,7 @@ function BetRow(props: {
formatMoney( formatMoney(
isResolved isResolved
? resolvedPayout(contract, bet) ? 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 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 const profit = saleAmount - bet.amount
return ( return (

View File

@ -1,12 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { NumericContract } from 'common/contract' import { NumericContract, PseudoNumericContract } from 'common/contract'
import { getMappedBucket } from 'common/calculate-dpm' import { getMappedBucket } from 'common/calculate-dpm'
import { NumberInput } from './number-input' import { NumberInput } from './number-input'
export function BucketInput(props: { export function BucketInput(props: {
contract: NumericContract contract: NumericContract | PseudoNumericContract
isSubmitting?: boolean isSubmitting?: boolean
onBucketChange: (value?: number, bucket?: string) => void onBucketChange: (value?: number, bucket?: string) => void
}) { }) {
@ -24,7 +24,10 @@ export function BucketInput(props: {
return return
} }
const bucket = getMappedBucket(value, contract) const bucket =
contract.outcomeType === 'PSEUDO_NUMERIC'
? ''
: getMappedBucket(value, contract)
onBucketChange(value, bucket) onBucketChange(value, bucket)
} }

View File

@ -52,10 +52,7 @@ export function ContractCard(props: {
const showQuickBet = const showQuickBet =
user && user &&
!marketClosed && !marketClosed &&
!( (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined
) &&
outcomeType !== 'NUMERIC' &&
!hideQuickBet !hideQuickBet
return ( return (

View File

@ -16,7 +16,7 @@ import {
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetRow from '../bet-row' import BetRow from '../bet-row'
import { AnswersGraph } from '../answers/answers-graph' import { AnswersGraph } from '../answers/answers-graph'
import { Contract } from 'common/contract' import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractDescription } from './contract-description' import { ContractDescription } from './contract-description'
import { ContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { ShareMarket } from '../share-market' import { ShareMarket } from '../share-market'
@ -70,6 +70,13 @@ export const ContractOverview = (props: {
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} /> <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} />} {tradingAllowed(contract) && <BetRow contract={contract} />}
</Row> </Row>
) : isPseudoNumeric ? ( ) : isPseudoNumeric ? (

View File

@ -7,7 +7,13 @@ import {
} from 'common/calculate' } from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm' import { getExpectedValue } from 'common/calculate-dpm'
import { User } from 'common/user' import { User } from 'common/user'
import { Contract, NumericContract, resolution } from 'common/contract' import {
BinaryContract,
Contract,
NumericContract,
PseudoNumericContract,
resolution,
} from 'common/contract'
import { import {
formatLargeNumber, formatLargeNumber,
formatMoney, 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 TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { OUTCOME_TO_COLOR } from '../outcome-label' 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 { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUnfilledBets } from 'web/hooks/use-bets'
const BET_SIZE = 10 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 { contract, user } = props
const { mechanism, outcomeType } = contract const { mechanism, outcomeType } = contract
const isCpmm = mechanism === 'cpmm-1' const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id) const userBets = useUserContractBets(user.id, contract.id)
const topAnswer = const unfilledBets = useUnfilledBets(contract.id) ?? []
outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
// TODO: yes/no from useSaveShares doesn't work on numeric contracts const { hasYesShares, hasNoShares, yesShares, noShares } =
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( useSaveBinaryShares(contract, userBets)
contract, const hasUpShares = hasYesShares
userBets, const hasDownShares = hasNoShares && !hasUpShares
topAnswer?.number.toString() || undefined
)
const hasUpShares =
yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC')
const hasDownShares =
noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC'
const [upHover, setUpHover] = useState(false) const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = 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) const maxSharesSold = BET_SIZE / (sellOutcome === 'YES' ? prob : 1 - prob)
sharesSold = Math.min(oppositeShares, maxSharesSold) sharesSold = Math.min(oppositeShares, maxSharesSold)
const { newPool, saleValue } = calculateCpmmSale( const { cpmmState, saleValue } = calculateCpmmSale(
contract, contract,
sharesSold, sharesSold,
sellOutcome sellOutcome,
unfilledBets
) )
saleAmount = saleValue 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 ( return (
<Col <Col
className={clsx( className={clsx(
@ -158,7 +155,7 @@ export function QuickBet(props: { contract: Contract; user: User }) {
{formatMoney(10)} {formatMoney(10)}
</div> </div>
{hasUpShares > 0 ? ( {hasUpShares ? (
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
@ -193,7 +190,7 @@ export function QuickBet(props: { contract: Contract; user: User }) {
onMouseLeave={() => setDownHover(false)} onMouseLeave={() => setDownHover(false)}
onClick={() => placeQuickBet('DOWN')} onClick={() => placeQuickBet('DOWN')}
></div> ></div>
{hasDownShares > 0 ? ( {hasDownShares ? (
<TriangleDownFillIcon <TriangleDownFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',

View File

@ -31,7 +31,9 @@ export function ContractActivity(props: {
const comments = updatedComments ?? props.comments const comments = updatedComments ?? props.comments
const updatedBets = useBets(contract.id) 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( const items = getSpecificContractActivityItems(
contract, contract,
bets, bets,

View File

@ -34,7 +34,7 @@ import {
TruncatedComment, TruncatedComment,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
import { FeedBet } from 'web/components/feed/feed-bets' import { FeedBet } from 'web/components/feed/feed-bets'
import { NumericContract } from 'common/contract' import { CPMMBinaryContract, NumericContract } from 'common/contract'
import { FeedLiquidity } from './feed-liquidity' import { FeedLiquidity } from './feed-liquidity'
export function FeedItems(props: { export function FeedItems(props: {
@ -68,7 +68,10 @@ export function FeedItems(props: {
))} ))}
</div> </div>
{outcomeType === 'BINARY' && tradingAllowed(contract) && ( {outcomeType === 'BINARY' && tradingAllowed(contract) && (
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} /> <BetRow
contract={contract as CPMMBinaryContract}
className={clsx('mb-2', betRowClassName)}
/>
)} )}
</div> </div>
) )

View File

@ -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>
)
}

View File

@ -96,7 +96,7 @@ export function NumericResolutionPanel(props: {
{outcomeMode === 'NUMBER' && ( {outcomeMode === 'NUMBER' && (
<BucketInput <BucketInput
contract={contract as any} contract={contract}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onBucketChange={(v, o) => (setValue(v), setOutcome(o))} onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
/> />

View File

@ -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>
)
}

View File

@ -6,7 +6,7 @@ import { Row } from './layout/row'
import { formatWithCommas } from 'common/util/format' import { formatWithCommas } from 'common/util/format'
import { OutcomeLabel } from './outcome-label' import { OutcomeLabel } from './outcome-label'
import { useUserContractBets } from 'web/hooks/use-user-bets' 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' import { SellSharesModal } from './sell-modal'
export function SellRow(props: { export function SellRow(props: {
@ -20,16 +20,7 @@ export function SellRow(props: {
const [showSellModal, setShowSellModal] = useState(false) const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract const { mechanism } = contract
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( const { sharesOutcome, shares } = useSaveBinaryShares(contract, userBets)
contract,
userBets
)
const floorShares = yesFloorShares || noFloorShares
const sharesOutcome = yesFloorShares
? 'YES'
: noFloorShares
? 'NO'
: undefined
if (sharesOutcome && user && mechanism === 'cpmm-1') { if (sharesOutcome && user && mechanism === 'cpmm-1') {
return ( return (
@ -37,7 +28,7 @@ export function SellRow(props: {
<Col className={className}> <Col className={className}>
<Row className="items-center justify-between gap-2 "> <Row className="items-center justify-between gap-2 ">
<div> <div>
You have {formatWithCommas(floorShares)}{' '} You have {formatWithCommas(shares)}{' '}
<OutcomeLabel <OutcomeLabel
outcome={sharesOutcome} outcome={sharesOutcome}
contract={contract} contract={contract}
@ -64,7 +55,7 @@ export function SellRow(props: {
contract={contract} contract={contract}
user={user} user={user}
userBets={userBets ?? []} userBets={userBets ?? []}
shares={yesShares || noShares} shares={shares}
sharesOutcome={sharesOutcome} sharesOutcome={sharesOutcome}
setOpen={setShowSellModal} setOpen={setShowSellModal}
/> />

View File

@ -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,
}
}

View File

@ -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,
}
)
}

View File

@ -4,8 +4,10 @@ import {
Bet, Bet,
listenForBets, listenForBets,
listenForRecentBets, listenForRecentBets,
listenForUnfilledBets,
withoutAnteBets, withoutAnteBets,
} from 'web/lib/firebase/bets' } from 'web/lib/firebase/bets'
import { LimitBet } from 'common/bet'
export const useBets = (contractId: string) => { export const useBets = (contractId: string) => {
const [bets, setBets] = useState<Bet[] | undefined>() const [bets, setBets] = useState<Bet[] | undefined>()
@ -36,3 +38,12 @@ export const useRecentBets = () => {
useEffect(() => listenForRecentBets(setRecentBets), []) useEffect(() => listenForRecentBets(setRecentBets), [])
return recentBets return recentBets
} }
export const useUnfilledBets = (contractId: string) => {
const [unfilledBets, setUnfilledBets] = useState<LimitBet[] | undefined>()
useEffect(
() => listenForUnfilledBets(contractId, setUnfilledBets),
[contractId]
)
return unfilledBets
}

View File

@ -1,11 +1,12 @@
import { useRef } from 'react' import { useRef } from 'react'
import { useEvent } from './use-event'
// Focus helper from https://stackoverflow.com/a/54159564/1222351 // Focus helper from https://stackoverflow.com/a/54159564/1222351
export function useFocus(): [React.RefObject<HTMLElement>, () => void] { export function useFocus(): [React.RefObject<HTMLElement>, () => void] {
const htmlElRef = useRef<HTMLElement>(null) const htmlElRef = useRef<HTMLElement>(null)
const setFocus = () => { const setFocus = useEvent(() => {
htmlElRef.current && htmlElRef.current.focus() htmlElRef.current && htmlElRef.current.focus()
} })
return [htmlElRef, setFocus] return [htmlElRef, setFocus]
} }

View File

@ -82,6 +82,10 @@ export function placeBet(params: any) {
return call(getFunctionUrl('placebet'), 'POST', params) return call(getFunctionUrl('placebet'), 'POST', params)
} }
export function cancelBet(params: { betId: string }) {
return call(getFunctionUrl('cancelbet'), 'POST', params)
}
export function sellShares(params: any) { export function sellShares(params: any) {
return call(getFunctionUrl('sellshares'), 'POST', params) return call(getFunctionUrl('sellshares'), 'POST', params)
} }

View File

@ -15,7 +15,7 @@ import {
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { db } from './init' import { db } from './init'
import { Bet } from 'common/bet' import { Bet, LimitBet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { getValues, listenForValues } from './utils' import { getValues, listenForValues } from './utils'
import { getContractFromId } from './contracts' 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[]) { export function withoutAnteBets(contract: Contract, bets?: Bet[]) {
const { createdTime } = contract const { createdTime } = contract

View File

@ -39,6 +39,7 @@ import { FeedBet } from 'web/components/feed/feed-bets'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import ContractEmbedPage from '../embed/[username]/[contractSlug]' import ContractEmbedPage from '../embed/[username]/[contractSlug]'
import { useBets } from 'web/hooks/use-bets' import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
@ -127,6 +128,7 @@ export function ContractPageContent(
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const user = useUser() const user = useUser()
const { width, height } = useWindowSize() const { width, height } = useWindowSize()
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
@ -169,7 +171,10 @@ export function ContractPageContent(
(isNumeric ? ( (isNumeric ? (
<NumericBetPanel className="hidden xl:flex" contract={contract} /> <NumericBetPanel className="hidden xl:flex" contract={contract} />
) : ( ) : (
<BetPanel className="hidden xl:flex" contract={contract} /> <BetPanel
className="hidden xl:flex"
contract={contract as CPMMBinaryContract}
/>
))} ))}
{allowResolve && {allowResolve &&
(isNumeric || isPseudoNumeric ? ( (isNumeric || isPseudoNumeric ? (

View File

@ -1,5 +1,5 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract, CPMMBinaryContract } from 'common/contract'
import { DOMAIN } from 'common/envs/constants' import { DOMAIN } from 'common/envs/constants'
import { AnswersGraph } from 'web/components/answers/answers-graph' import { AnswersGraph } from 'web/components/answers/answers-graph'
import BetRow from 'web/components/bet-row' import BetRow from 'web/components/bet-row'
@ -112,7 +112,10 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
{isBinary && ( {isBinary && (
<Row className="items-center gap-4"> <Row className="items-center gap-4">
<BetRow contract={contract} betPanelClassName="scale-75" /> <BetRow
contract={contract as CPMMBinaryContract}
betPanelClassName="scale-75"
/>
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
</Row> </Row>
)} )}

View File

@ -795,6 +795,8 @@ function getSourceIdForLinkComponent(
return sourceId return sourceId
case 'contract': case 'contract':
return '' return ''
case 'bet':
return ''
default: default:
return sourceId return sourceId
} }
@ -861,8 +863,16 @@ function NotificationTextLabel(props: {
{'+' + formatMoney(parseInt(sourceText))} {'+' + formatMoney(parseInt(sourceText))}
</span> </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 ( return (
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
<Linkify text={defaultText} /> <Linkify text={defaultText} />
@ -913,6 +923,9 @@ function getReasonForShowingNotification(
else if (sourceSlug) reasonText = 'joined because you shared' else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you' else reasonText = 'joined because of you'
break break
case 'bet':
reasonText = 'bet against you'
break
default: default:
reasonText = '' reasonText = ''
} }