manifold/web/components/bet-panel.tsx
James Grugett 80ae551ca9
🧾 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>
2022-07-10 13:05:44 -05:00

556 lines
16 KiB
TypeScript

import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { partition, sumBy } from 'lodash'
import { SwitchHorizontalIcon } from '@heroicons/react/solid'
import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import { YesNoSelector } from './yes-no-selector'
import {
formatMoney,
formatMoneyWithDecimals,
formatPercent,
formatWithCommas,
} from 'common/util/format'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { Title } from './title'
import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { sellShares } from 'web/lib/firebase/api-call'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label'
import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import {
calculateCpmmSale,
getCpmmProbability,
getCpmmFees,
} from 'common/calculate-cpmm'
import {
getFormattedMappedValue,
getPseudoProbability,
} from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { SignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { ProbabilityInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { removeUndefinedProps } from 'common/util/object'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets'
import { BucketInput } from './bucket-input'
export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
}) {
const { contract, className } = props
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false)
return (
<Col className={className}>
<SellRow
contract={contract}
user={user}
className={'rounded-t-md bg-gray-100 px-4 py-5'}
/>
<Col
className={clsx(
'relative rounded-b-md bg-white px-8 py-6',
!sharesOutcome && 'rounded-t-md',
className
)}
>
<Row className="align-center justify-between">
<div className="mb-6 text-2xl">
{isLimitOrder ? <>Limit bet</> : <>Place your bet</>}
</div>
<button
className="btn btn-ghost btn-sm text-sm normal-case"
onClick={() => setIsLimitOrder(!isLimitOrder)}
>
<SwitchHorizontalIcon className="inline h-6 w-6" />
</button>
</Row>
<BuyPanel
contract={contract}
user={user}
isLimitOrder={isLimitOrder}
unfilledBets={unfilledBets}
/>
<SignUpPrompt />
</Col>
{yourUnfilledBets.length > 0 && (
<LimitBets
className="mt-4"
contract={contract}
bets={yourUnfilledBets}
/>
)}
</Col>
)
}
export function SimpleBetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
selected?: 'YES' | 'NO'
onBetSuccess?: () => void
}) {
const { contract, className, selected, onBetSuccess } = props
const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
return (
<Col className={className}>
<Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}>
<Row className="justify-between">
<Title
className={clsx('!mt-0')}
text={isLimitOrder ? 'Limit bet' : 'Place a trade'}
/>
<button
className="btn btn-ghost btn-sm text-sm normal-case"
onClick={() => setIsLimitOrder(!isLimitOrder)}
>
<SwitchHorizontalIcon className="inline h-6 w-6" />
</button>
</Row>
<BuyPanel
contract={contract}
user={user}
unfilledBets={unfilledBets}
selected={selected}
onBuySuccess={onBetSuccess}
isLimitOrder={isLimitOrder}
/>
<SignUpPrompt />
</Col>
{yourUnfilledBets.length > 0 && (
<LimitBets
className="mt-4"
contract={contract}
bets={yourUnfilledBets}
/>
)}
</Col>
)
}
function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
isLimitOrder?: boolean
selected?: 'YES' | 'NO'
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } =
props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [limitProb, setLimitProb] = useState<number | undefined>(
Math.round(100 * initialProb)
)
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const [inputRef, focusAmountInput] = useFocus()
useEffect(() => {
if (selected) {
if (isIOS()) window.scrollTo(0, window.scrollY + 200)
focusAmountInput()
}
}, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') {
setBetChoice(choice)
setWasSubmitted(false)
focusAmountInput()
}
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
if (!betChoice) {
setBetChoice('YES')
}
}
async function submitBet() {
if (!user || !betAmount) return
if (isLimitOrder && limitProb === undefined) return
const limitProbScaled =
isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined
setError(undefined)
setIsSubmitting(true)
placeBet(
removeUndefinedProps({
amount: betAmount,
outcome: betChoice,
contractId: contract.id,
limitProb: limitProbScaled,
})
)
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
if (onBuySuccess) onBuySuccess()
})
.catch((e) => {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
track('bet', {
location: 'bet panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: betAmount,
outcome: betChoice,
})
}
const betDisabled = isSubmitting || !betAmount || error
const limitProbFrac = (limitProb ?? 0) / 100
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
betChoice ?? 'YES',
betAmount ?? 0,
contract,
isLimitOrder ? limitProbFrac : undefined,
unfilledBets as LimitBet[]
)
const resultProb = getCpmmProbability(newPool, newP)
const remainingMatched = isLimitOrder
? ((newBet.orderAmount ?? 0) - newBet.amount) /
(betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac)
: 0
const currentPayout = newBet.shares + remainingMatched
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
const cpmmFees = getCpmmFees(
contract,
betAmount ?? 0,
betChoice ?? 'YES'
).totalFees
const format = getFormattedMappedValue(contract)
return (
<>
<YesNoSelector
className="mb-4"
btnClassName="flex-1"
selected={betChoice}
onSelect={(choice) => onBetChoice(choice)}
isPseudoNumeric={isPseudoNumeric}
/>
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
onChange={onBetChange}
error={error}
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
/>
{isLimitOrder && (
<>
<Row className="my-3 items-center gap-2 text-left text-sm text-gray-500">
Limit {isPseudoNumeric ? 'value' : 'probability'}
<InfoTooltip
text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${
isPseudoNumeric ? 'value' : 'probability'
} and wait to match other bets.`}
/>
</Row>
{isPseudoNumeric ? (
<BucketInput
contract={contract}
onBucketChange={(value) =>
setLimitProb(
value === undefined
? undefined
: 100 *
getPseudoProbability(
value,
contract.min,
contract.max,
contract.isLogScale
)
)
}
isSubmitting={isSubmitting}
/>
) : (
<ProbabilityInput
inputClassName="w-full max-w-none"
prob={limitProb}
onChange={setLimitProb}
disabled={isSubmitting}
/>
)}
</>
)}
<Col className="mt-3 w-full gap-3">
{!isLimitOrder && (
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
</Row>
)}
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
</>
)}
</div>
<InfoTooltip
text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`}
/>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(currentPayout)}
</span>
(+{currentReturnPercent})
</div>
</Row>
</Col>
<Spacer h={8} />
{user && (
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: betChoice === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
</button>
)}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
</>
)
}
export function SellPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
userBets: Bet[]
shares: number
sharesOutcome: 'YES' | 'NO'
user: User
onSellSuccess?: () => void
}) {
const { contract, shares, sharesOutcome, userBets, user, onSellSuccess } =
props
const [amount, setAmount] = useState<number | undefined>(shares)
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const betDisabled = isSubmitting || !amount || error
// Sell all shares if remaining shares would be < 1
const sellQuantity = amount === Math.floor(shares) ? shares : amount
async function submitSell() {
if (!user || !amount) return
setError(undefined)
setIsSubmitting(true)
await sellShares({
shares: sellQuantity,
outcome: sharesOutcome,
contractId: contract.id,
})
.then((r) => {
console.log('Sold shares. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setAmount(undefined)
if (onSellSuccess) onSellSuccess()
})
.catch((e) => {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error selling')
}
setIsSubmitting(false)
})
track('sell shares', {
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
shares: sellQuantity,
outcome: sharesOutcome,
})
}
const initialProb = getProbability(contract)
const { cpmmState, saleValue } = calculateCpmmSale(
contract,
sellQuantity ?? 0,
sharesOutcome,
unfilledBets
)
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = partition(
openUserBets,
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
sumBy(yesBets, (bet) => bet.shares),
sumBy(noBets, (bet) => bet.shares),
]
const ownedShares = Math.round(yesShares) || Math.round(noShares)
const onAmountChange = (amount: number | undefined) => {
setAmount(amount)
// Check for errors.
if (amount !== undefined) {
if (amount > ownedShares) {
setError(`Maximum ${formatWithCommas(Math.floor(ownedShares))} shares`)
} else {
setError(undefined)
}
}
}
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const format = getFormattedMappedValue(contract)
return (
<>
<AmountInput
amount={
amount
? Math.round(amount) === 0
? 0
: Math.floor(amount)
: undefined
}
onChange={onAmountChange}
label="Qty"
error={error}
disabled={isSubmitting}
inputClassName="w-full"
/>
<Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds
<span className="text-neutral">{formatMoney(saleValue)}</span>
</Row>
<Row className="items-center justify-between">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
</Row>
</Col>
<Spacer h={8} />
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: sharesOutcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitSell}
>
{isSubmitting ? 'Submitting...' : 'Submit sell'}
</button>
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
</>
)
}