1033 lines
30 KiB
TypeScript
1033 lines
30 KiB
TypeScript
import clsx from 'clsx'
|
|
import React, { useState } from 'react'
|
|
import { clamp, partition, sumBy } from 'lodash'
|
|
|
|
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 {
|
|
formatLargeNumber,
|
|
formatMoney,
|
|
formatPercent,
|
|
formatWithCommas,
|
|
} from 'common/util/format'
|
|
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
|
|
import { User } from 'web/lib/firebase/users'
|
|
import { Bet, LimitBet } from 'common/bet'
|
|
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
|
import {
|
|
BinaryOutcomeLabel,
|
|
HigherLabel,
|
|
LowerLabel,
|
|
NoLabel,
|
|
YesLabel,
|
|
} from './outcome-label'
|
|
import { getContractBetMetrics, getProbability } from 'common/calculate'
|
|
import { useFocus } from 'web/hooks/use-focus'
|
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
|
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
|
import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
|
|
import { SellRow } from './sell-row'
|
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
|
import { ProbabilityOrNumericInput } from './probability-input'
|
|
import { track } from 'web/lib/service/analytics'
|
|
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
|
|
import { LimitBets } from './limit-bets'
|
|
import { PillButton } from './buttons/pill-button'
|
|
import { YesNoSelector } from './yes-no-selector'
|
|
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
|
import { isAndroid, isIOS } from 'web/lib/util/device'
|
|
import { WarningConfirmationButton } from './warning-confirmation-button'
|
|
import { MarketIntroPanel } from './market-intro-panel'
|
|
import { Modal } from './layout/modal'
|
|
import { Title } from './title'
|
|
import toast from 'react-hot-toast'
|
|
import { CheckIcon } from '@heroicons/react/solid'
|
|
import { Button } from './button'
|
|
|
|
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, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
|
contract.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-6 py-6',
|
|
!sharesOutcome && 'rounded-t-md',
|
|
className
|
|
)}
|
|
>
|
|
{user ? (
|
|
<>
|
|
<QuickOrLimitBet
|
|
isLimitOrder={isLimitOrder}
|
|
setIsLimitOrder={setIsLimitOrder}
|
|
hideToggle={!user}
|
|
/>
|
|
<BuyPanel
|
|
hidden={isLimitOrder}
|
|
contract={contract}
|
|
user={user}
|
|
unfilledBets={unfilledBets}
|
|
balanceByUserId={balanceByUserId}
|
|
/>
|
|
<LimitOrderPanel
|
|
hidden={!isLimitOrder}
|
|
contract={contract}
|
|
user={user}
|
|
unfilledBets={unfilledBets}
|
|
balanceByUserId={balanceByUserId}
|
|
/>
|
|
</>
|
|
) : (
|
|
<MarketIntroPanel />
|
|
)}
|
|
</Col>
|
|
|
|
{user && unfilledBets.length > 0 && (
|
|
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
|
)}
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
export function SimpleBetPanel(props: {
|
|
contract: CPMMBinaryContract | PseudoNumericContract
|
|
className?: string
|
|
hasShares?: boolean
|
|
onBetSuccess?: () => void
|
|
}) {
|
|
const { contract, className, hasShares, onBetSuccess } = props
|
|
|
|
const user = useUser()
|
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
|
|
|
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
|
contract.id
|
|
)
|
|
|
|
return (
|
|
<Col className={className}>
|
|
<SellRow
|
|
contract={contract}
|
|
user={user}
|
|
className={'rounded-t-md bg-gray-100 px-4 py-5'}
|
|
/>
|
|
<Col
|
|
className={clsx(
|
|
!hasShares && 'rounded-t-md',
|
|
'rounded-b-md bg-white px-8 py-6'
|
|
)}
|
|
>
|
|
<QuickOrLimitBet
|
|
isLimitOrder={isLimitOrder}
|
|
setIsLimitOrder={setIsLimitOrder}
|
|
hideToggle={!user}
|
|
/>
|
|
<BuyPanel
|
|
hidden={isLimitOrder}
|
|
contract={contract}
|
|
user={user}
|
|
unfilledBets={unfilledBets}
|
|
balanceByUserId={balanceByUserId}
|
|
onBuySuccess={onBetSuccess}
|
|
/>
|
|
<LimitOrderPanel
|
|
hidden={!isLimitOrder}
|
|
contract={contract}
|
|
user={user}
|
|
unfilledBets={unfilledBets}
|
|
balanceByUserId={balanceByUserId}
|
|
onBuySuccess={onBetSuccess}
|
|
/>
|
|
|
|
<BetSignUpPrompt />
|
|
|
|
{!user && <PlayMoneyDisclaimer />}
|
|
</Col>
|
|
|
|
{unfilledBets.length > 0 && (
|
|
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
|
)}
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
export function BuyPanel(props: {
|
|
contract: CPMMBinaryContract | PseudoNumericContract
|
|
user: User | null | undefined
|
|
unfilledBets: LimitBet[]
|
|
balanceByUserId: { [userId: string]: number }
|
|
hidden: boolean
|
|
onBuySuccess?: () => void
|
|
mobileView?: boolean
|
|
}) {
|
|
const {
|
|
contract,
|
|
user,
|
|
unfilledBets,
|
|
balanceByUserId,
|
|
hidden,
|
|
onBuySuccess,
|
|
mobileView,
|
|
} = props
|
|
|
|
const initialProb = getProbability(contract)
|
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
|
|
|
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
|
|
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
|
const [error, setError] = useState<string | undefined>()
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
const [inputRef, focusAmountInput] = useFocus()
|
|
|
|
function onBetChoice(choice: 'YES' | 'NO') {
|
|
setOutcome(choice)
|
|
|
|
if (!isIOS() && !isAndroid()) {
|
|
focusAmountInput()
|
|
}
|
|
}
|
|
|
|
function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
|
|
if (outcome === choice) {
|
|
setOutcome(undefined)
|
|
} else {
|
|
setOutcome(choice)
|
|
}
|
|
if (!isIOS() && !isAndroid()) {
|
|
focusAmountInput()
|
|
}
|
|
}
|
|
|
|
function onBetChange(newAmount: number | undefined) {
|
|
setBetAmount(newAmount)
|
|
if (!outcome) {
|
|
setOutcome('YES')
|
|
}
|
|
}
|
|
|
|
async function submitBet() {
|
|
if (!user || !betAmount) return
|
|
|
|
setError(undefined)
|
|
setIsSubmitting(true)
|
|
|
|
placeBet({
|
|
outcome,
|
|
amount: betAmount,
|
|
contractId: contract.id,
|
|
})
|
|
.then((r) => {
|
|
console.log('placed bet. Result:', r)
|
|
setIsSubmitting(false)
|
|
setBetAmount(undefined)
|
|
if (onBuySuccess) onBuySuccess()
|
|
else {
|
|
toast('Trade submitted!', {
|
|
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
|
})
|
|
}
|
|
})
|
|
.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,
|
|
isLimitOrder: false,
|
|
})
|
|
}
|
|
|
|
const betDisabled = isSubmitting || !betAmount || !!error
|
|
|
|
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
|
|
outcome ?? 'YES',
|
|
betAmount ?? 0,
|
|
contract,
|
|
undefined,
|
|
unfilledBets,
|
|
balanceByUserId
|
|
)
|
|
|
|
const [seeLimit, setSeeLimit] = useState(false)
|
|
const resultProb = getCpmmProbability(newPool, newP)
|
|
const probStayedSame =
|
|
formatPercent(resultProb) === formatPercent(initialProb)
|
|
|
|
const probChange = Math.abs(resultProb - initialProb)
|
|
const currentPayout = newBet.shares
|
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
|
const currentReturnPercent = formatPercent(currentReturn)
|
|
|
|
const format = getFormattedMappedValue(contract)
|
|
|
|
const getValue = getMappedValue(contract)
|
|
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
|
|
const displayedDifference = isPseudoNumeric
|
|
? formatLargeNumber(rawDifference)
|
|
: formatPercent(rawDifference)
|
|
|
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
|
|
|
const warning =
|
|
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
|
|
? `You might not want to spend ${formatPercent(
|
|
bankrollFraction
|
|
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
|
|
user?.balance ?? 0
|
|
)}`
|
|
: (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1
|
|
? `Are you sure you want to move the market by ${displayedDifference}?`
|
|
: undefined
|
|
|
|
return (
|
|
<Col className={hidden ? 'hidden' : ''}>
|
|
<YesNoSelector
|
|
className="mb-4"
|
|
btnClassName="flex-1"
|
|
selected={outcome}
|
|
onSelect={(choice) => {
|
|
if (mobileView) {
|
|
mobileOnBetChoice(choice)
|
|
} else {
|
|
onBetChoice(choice)
|
|
}
|
|
}}
|
|
isPseudoNumeric={isPseudoNumeric}
|
|
/>
|
|
|
|
<Col
|
|
className={clsx(
|
|
mobileView
|
|
? outcome === 'NO'
|
|
? 'bg-red-25'
|
|
: outcome === 'YES'
|
|
? 'bg-teal-50'
|
|
: 'hidden'
|
|
: 'bg-white',
|
|
mobileView ? 'rounded-lg px-4 py-2' : 'px-0'
|
|
)}
|
|
>
|
|
<Row className="mt-3 w-full gap-3">
|
|
<Col className="w-1/2 text-sm">
|
|
<Col className="text-greyscale-4 flex-nowrap whitespace-nowrap text-xs">
|
|
<div>
|
|
{isPseudoNumeric ? (
|
|
'Max payout'
|
|
) : (
|
|
<>Payout if {outcome ?? 'YES'}</>
|
|
)}
|
|
</div>
|
|
</Col>
|
|
<div>
|
|
<span className="whitespace-nowrap text-xl">
|
|
{formatMoney(currentPayout)}
|
|
</span>
|
|
<span className="text-greyscale-4 text-xs">
|
|
{' '}
|
|
+{currentReturnPercent}
|
|
</span>
|
|
</div>
|
|
</Col>
|
|
<Col className="w-1/2 text-sm">
|
|
<div className="text-greyscale-4 text-xs">
|
|
{isPseudoNumeric ? 'Estimated value' : 'New Probability'}
|
|
</div>
|
|
{probStayedSame ? (
|
|
<div className="text-xl">{format(initialProb)}</div>
|
|
) : (
|
|
<div className="text-xl">
|
|
{format(resultProb)}
|
|
<span className={clsx('text-greyscale-4 text-xs')}>
|
|
{isPseudoNumeric ? (
|
|
<></>
|
|
) : (
|
|
<>
|
|
{' '}
|
|
{outcome != 'NO' && '+'}
|
|
{format(resultProb - initialProb)}
|
|
</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
<Row className="text-greyscale-4 mt-4 mb-1 justify-between text-left text-xs">
|
|
Amount
|
|
</Row>
|
|
|
|
<BuyAmountInput
|
|
inputClassName="w-full max-w-none"
|
|
amount={betAmount}
|
|
onChange={onBetChange}
|
|
error={error}
|
|
setError={setError}
|
|
disabled={isSubmitting}
|
|
inputRef={inputRef}
|
|
showSliderOnMobile
|
|
/>
|
|
|
|
<Spacer h={8} />
|
|
|
|
{user && (
|
|
<WarningConfirmationButton
|
|
marketType="binary"
|
|
amount={betAmount}
|
|
warning={warning}
|
|
onSubmit={submitBet}
|
|
isSubmitting={isSubmitting}
|
|
disabled={!!betDisabled || outcome === undefined}
|
|
size="xl"
|
|
color={outcome === 'NO' ? 'red' : 'green'}
|
|
actionLabel="Wager"
|
|
/>
|
|
)}
|
|
<button
|
|
className="text-greyscale-6 mx-auto mt-3 select-none text-sm underline xl:hidden"
|
|
onClick={() => setSeeLimit(true)}
|
|
>
|
|
Advanced
|
|
</button>
|
|
<Modal
|
|
open={seeLimit}
|
|
setOpen={setSeeLimit}
|
|
position="center"
|
|
className="rounded-lg bg-white px-4 pb-4"
|
|
>
|
|
<Title text="Limit Order" />
|
|
<LimitOrderPanel
|
|
hidden={!seeLimit}
|
|
contract={contract}
|
|
user={user}
|
|
unfilledBets={unfilledBets}
|
|
balanceByUserId={balanceByUserId}
|
|
/>
|
|
<LimitBets
|
|
contract={contract}
|
|
bets={unfilledBets as LimitBet[]}
|
|
className="mt-4"
|
|
/>
|
|
</Modal>
|
|
</Col>
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
function LimitOrderPanel(props: {
|
|
contract: CPMMBinaryContract | PseudoNumericContract
|
|
user: User | null | undefined
|
|
unfilledBets: LimitBet[]
|
|
balanceByUserId: { [userId: string]: number }
|
|
hidden: boolean
|
|
onBuySuccess?: () => void
|
|
}) {
|
|
const {
|
|
contract,
|
|
user,
|
|
unfilledBets,
|
|
balanceByUserId,
|
|
hidden,
|
|
onBuySuccess,
|
|
} = props
|
|
|
|
const initialProb = getProbability(contract)
|
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
|
|
|
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
|
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
|
|
const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
|
|
const [error, setError] = useState<string | undefined>()
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
const rangeError =
|
|
lowLimitProb !== undefined &&
|
|
highLimitProb !== undefined &&
|
|
lowLimitProb >= highLimitProb
|
|
|
|
const outOfRangeError =
|
|
(lowLimitProb !== undefined &&
|
|
(lowLimitProb <= 0 || lowLimitProb >= 100)) ||
|
|
(highLimitProb !== undefined &&
|
|
(highLimitProb <= 0 || highLimitProb >= 100))
|
|
|
|
const hasYesLimitBet = lowLimitProb !== undefined && !!betAmount
|
|
const hasNoLimitBet = highLimitProb !== undefined && !!betAmount
|
|
const hasTwoBets = hasYesLimitBet && hasNoLimitBet
|
|
|
|
const betDisabled =
|
|
isSubmitting ||
|
|
!betAmount ||
|
|
rangeError ||
|
|
outOfRangeError ||
|
|
!!error ||
|
|
(!hasYesLimitBet && !hasNoLimitBet)
|
|
|
|
const yesLimitProb =
|
|
lowLimitProb === undefined
|
|
? undefined
|
|
: clamp(lowLimitProb / 100, 0.001, 0.999)
|
|
const noLimitProb =
|
|
highLimitProb === undefined
|
|
? undefined
|
|
: clamp(highLimitProb / 100, 0.001, 0.999)
|
|
|
|
const amount = betAmount ?? 0
|
|
const shares =
|
|
yesLimitProb !== undefined && noLimitProb !== undefined
|
|
? Math.min(amount / yesLimitProb, amount / (1 - noLimitProb))
|
|
: yesLimitProb !== undefined
|
|
? amount / yesLimitProb
|
|
: noLimitProb !== undefined
|
|
? amount / (1 - noLimitProb)
|
|
: 0
|
|
|
|
const yesAmount = shares * (yesLimitProb ?? 1)
|
|
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
|
|
|
function onBetChange(newAmount: number | undefined) {
|
|
setBetAmount(newAmount)
|
|
}
|
|
|
|
async function submitBet() {
|
|
if (!user || betDisabled) return
|
|
|
|
setError(undefined)
|
|
setIsSubmitting(true)
|
|
|
|
const betsPromise = hasTwoBets
|
|
? Promise.all([
|
|
placeBet({
|
|
outcome: 'YES',
|
|
amount: yesAmount,
|
|
limitProb: yesLimitProb,
|
|
contractId: contract.id,
|
|
}),
|
|
placeBet({
|
|
outcome: 'NO',
|
|
amount: noAmount,
|
|
limitProb: noLimitProb,
|
|
contractId: contract.id,
|
|
}),
|
|
])
|
|
: placeBet({
|
|
outcome: hasYesLimitBet ? 'YES' : 'NO',
|
|
amount: betAmount,
|
|
contractId: contract.id,
|
|
limitProb: hasYesLimitBet ? yesLimitProb : noLimitProb,
|
|
})
|
|
|
|
betsPromise
|
|
.catch((e) => {
|
|
if (e instanceof APIError) {
|
|
setError(e.toString())
|
|
} else {
|
|
console.error(e)
|
|
setError('Error placing bet')
|
|
}
|
|
setIsSubmitting(false)
|
|
})
|
|
.then((r) => {
|
|
console.log('placed bet. Result:', r)
|
|
setIsSubmitting(false)
|
|
setBetAmount(undefined)
|
|
setLowLimitProb(undefined)
|
|
setHighLimitProb(undefined)
|
|
if (onBuySuccess) onBuySuccess()
|
|
})
|
|
|
|
if (hasYesLimitBet) {
|
|
track('bet', {
|
|
location: 'bet panel',
|
|
outcomeType: contract.outcomeType,
|
|
slug: contract.slug,
|
|
contractId: contract.id,
|
|
amount: yesAmount,
|
|
outcome: 'YES',
|
|
limitProb: yesLimitProb,
|
|
isLimitOrder: true,
|
|
isRangeOrder: hasTwoBets,
|
|
})
|
|
}
|
|
if (hasNoLimitBet) {
|
|
track('bet', {
|
|
location: 'bet panel',
|
|
outcomeType: contract.outcomeType,
|
|
slug: contract.slug,
|
|
contractId: contract.id,
|
|
amount: noAmount,
|
|
outcome: 'NO',
|
|
limitProb: noLimitProb,
|
|
isLimitOrder: true,
|
|
isRangeOrder: hasTwoBets,
|
|
})
|
|
}
|
|
}
|
|
|
|
const {
|
|
currentPayout: yesPayout,
|
|
currentReturn: yesReturn,
|
|
totalFees: yesFees,
|
|
newBet: yesBet,
|
|
} = getBinaryBetStats(
|
|
'YES',
|
|
yesAmount,
|
|
contract,
|
|
yesLimitProb ?? initialProb,
|
|
unfilledBets,
|
|
balanceByUserId
|
|
)
|
|
const yesReturnPercent = formatPercent(yesReturn)
|
|
|
|
const {
|
|
currentPayout: noPayout,
|
|
currentReturn: noReturn,
|
|
totalFees: noFees,
|
|
newBet: noBet,
|
|
} = getBinaryBetStats(
|
|
'NO',
|
|
noAmount,
|
|
contract,
|
|
noLimitProb ?? initialProb,
|
|
unfilledBets,
|
|
balanceByUserId
|
|
)
|
|
const noReturnPercent = formatPercent(noReturn)
|
|
|
|
const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees
|
|
|
|
return (
|
|
<Col className={hidden ? 'hidden' : ''}>
|
|
<Row className="mt-1 mb-4 items-center gap-4">
|
|
<Col className="gap-2">
|
|
<div className="text-sm text-gray-500">
|
|
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
|
</div>
|
|
<ProbabilityOrNumericInput
|
|
contract={contract}
|
|
prob={lowLimitProb}
|
|
setProb={setLowLimitProb}
|
|
isSubmitting={isSubmitting}
|
|
placeholder="10"
|
|
/>
|
|
</Col>
|
|
<Col className="gap-2">
|
|
<div className="text-sm text-gray-500">
|
|
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
|
</div>
|
|
<ProbabilityOrNumericInput
|
|
contract={contract}
|
|
prob={highLimitProb}
|
|
setProb={setHighLimitProb}
|
|
isSubmitting={isSubmitting}
|
|
placeholder="90"
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
|
|
{outOfRangeError && (
|
|
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
|
Limit is out of range
|
|
</div>
|
|
)}
|
|
{rangeError && !outOfRangeError && (
|
|
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
|
{isPseudoNumeric ? 'HIGHER' : 'YES'} limit must be less than{' '}
|
|
{isPseudoNumeric ? 'LOWER' : 'NO'} limit
|
|
</div>
|
|
)}
|
|
|
|
<Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500">
|
|
<span>
|
|
Max amount<span className="ml-1 text-red-500">*</span>
|
|
</span>
|
|
<span className={'xl:hidden'}>
|
|
Balance: {formatMoney(user?.balance ?? 0)}
|
|
</span>
|
|
</Row>
|
|
|
|
<BuyAmountInput
|
|
inputClassName="w-full max-w-none"
|
|
amount={betAmount}
|
|
onChange={onBetChange}
|
|
error={error}
|
|
setError={setError}
|
|
disabled={isSubmitting}
|
|
showSliderOnMobile
|
|
/>
|
|
|
|
<Col className="mt-3 w-full gap-3">
|
|
{(hasTwoBets || (hasYesLimitBet && yesBet.amount !== 0)) && (
|
|
<Row className="items-center justify-between gap-2 text-sm">
|
|
<div className="whitespace-nowrap text-gray-500">
|
|
{isPseudoNumeric ? (
|
|
<HigherLabel />
|
|
) : (
|
|
<BinaryOutcomeLabel outcome={'YES'} />
|
|
)}{' '}
|
|
filled now
|
|
</div>
|
|
<div className="mr-2 whitespace-nowrap">
|
|
{formatMoney(yesBet.amount)} of{' '}
|
|
{formatMoney(yesBet.orderAmount ?? 0)}
|
|
</div>
|
|
</Row>
|
|
)}
|
|
{(hasTwoBets || (hasNoLimitBet && noBet.amount !== 0)) && (
|
|
<Row className="items-center justify-between gap-2 text-sm">
|
|
<div className="whitespace-nowrap text-gray-500">
|
|
{isPseudoNumeric ? (
|
|
<LowerLabel />
|
|
) : (
|
|
<BinaryOutcomeLabel outcome={'NO'} />
|
|
)}{' '}
|
|
filled now
|
|
</div>
|
|
<div className="mr-2 whitespace-nowrap">
|
|
{formatMoney(noBet.amount)} of{' '}
|
|
{formatMoney(noBet.orderAmount ?? 0)}
|
|
</div>
|
|
</Row>
|
|
)}
|
|
{hasTwoBets && (
|
|
<Row className="items-center justify-between gap-2 text-sm">
|
|
<div className="whitespace-nowrap text-gray-500">
|
|
Profit if both orders filled
|
|
</div>
|
|
<div className="mr-2 whitespace-nowrap">
|
|
{formatMoney(profitIfBothFilled)}
|
|
</div>
|
|
</Row>
|
|
)}
|
|
{hasYesLimitBet && !hasTwoBets && (
|
|
<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'
|
|
) : (
|
|
<>
|
|
Max <BinaryOutcomeLabel outcome={'YES'} /> payout
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* <InfoTooltip
|
|
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
|
/> */}
|
|
</Row>
|
|
<div>
|
|
<span className="mr-2 whitespace-nowrap">
|
|
{formatMoney(yesPayout)}
|
|
</span>
|
|
(+{yesReturnPercent})
|
|
</div>
|
|
</Row>
|
|
)}
|
|
{hasNoLimitBet && !hasTwoBets && (
|
|
<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'
|
|
) : (
|
|
<>
|
|
Max <BinaryOutcomeLabel outcome={'NO'} /> payout
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* <InfoTooltip
|
|
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
|
/> */}
|
|
</Row>
|
|
<div>
|
|
<span className="mr-2 whitespace-nowrap">
|
|
{formatMoney(noPayout)}
|
|
</span>
|
|
(+{noReturnPercent})
|
|
</div>
|
|
</Row>
|
|
)}
|
|
</Col>
|
|
|
|
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
|
|
|
|
{user && (
|
|
<Button
|
|
size="xl"
|
|
disabled={betDisabled}
|
|
color={'indigo'}
|
|
loading={isSubmitting}
|
|
className="flex-1"
|
|
onClick={submitBet}
|
|
>
|
|
{isSubmitting
|
|
? 'Submitting...'
|
|
: `Submit order${hasTwoBets ? 's' : ''}`}
|
|
</Button>
|
|
)}
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
function QuickOrLimitBet(props: {
|
|
isLimitOrder: boolean
|
|
setIsLimitOrder: (isLimitOrder: boolean) => void
|
|
hideToggle?: boolean
|
|
}) {
|
|
const { isLimitOrder, setIsLimitOrder, hideToggle } = props
|
|
|
|
return (
|
|
<Row className="align-center mb-4 justify-between">
|
|
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
|
|
{!hideToggle && (
|
|
<Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
|
<PillButton
|
|
selected={!isLimitOrder}
|
|
onSelect={() => {
|
|
setIsLimitOrder(false)
|
|
track('select quick order')
|
|
}}
|
|
xs={true}
|
|
>
|
|
Quick
|
|
</PillButton>
|
|
<PillButton
|
|
selected={isLimitOrder}
|
|
onSelect={() => {
|
|
setIsLimitOrder(true)
|
|
track('select limit order')
|
|
}}
|
|
xs={true}
|
|
>
|
|
Limit
|
|
</PillButton>
|
|
</Row>
|
|
)}
|
|
</Row>
|
|
)
|
|
}
|
|
|
|
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, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
|
contract.id
|
|
)
|
|
|
|
const betDisabled = isSubmitting || !amount || error !== undefined
|
|
|
|
// Sell all shares if remaining shares would be < 1
|
|
const isSellingAllShares = amount === Math.floor(shares)
|
|
|
|
const sellQuantity = isSellingAllShares ? shares : amount
|
|
|
|
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
|
const soldShares = Math.min(sellQuantity ?? 0, shares)
|
|
const saleFrac = soldShares / shares
|
|
const loanPaid = saleFrac * loanAmount
|
|
|
|
const { invested } = getContractBetMetrics(contract, userBets)
|
|
const costBasis = invested * saleFrac
|
|
|
|
async function submitSell() {
|
|
if (!user || !amount) return
|
|
|
|
setError(undefined)
|
|
setIsSubmitting(true)
|
|
|
|
await sellShares({
|
|
shares: isSellingAllShares ? undefined : amount,
|
|
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,
|
|
balanceByUserId
|
|
)
|
|
const netProceeds = saleValue - loanPaid
|
|
const profit = saleValue - costBasis
|
|
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
|
|
|
const getValue = getMappedValue(contract)
|
|
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
|
|
const displayedDifference =
|
|
contract.outcomeType === 'PSEUDO_NUMERIC'
|
|
? formatLargeNumber(rawDifference)
|
|
: formatPercent(rawDifference)
|
|
const probChange = Math.abs(resultProb - initialProb)
|
|
|
|
const warning =
|
|
probChange >= 0.3
|
|
? `Are you sure you want to move the market by ${displayedDifference}?`
|
|
: undefined
|
|
|
|
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)) : 0
|
|
}
|
|
onChange={onAmountChange}
|
|
label="Qty"
|
|
error={error}
|
|
disabled={isSubmitting}
|
|
inputClassName="w-full ml-1"
|
|
/>
|
|
|
|
<Col className="mt-3 w-full gap-3 text-sm">
|
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
|
Sale amount
|
|
<span className="text-gray-700">{formatMoney(saleValue)}</span>
|
|
</Row>
|
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
|
Profit
|
|
<span className="text-gray-700">{formatMoney(profit)}</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>
|
|
{loanPaid !== 0 && (
|
|
<>
|
|
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
|
|
Loan payment
|
|
<span className="text-gray-700">{formatMoney(-loanPaid)}</span>
|
|
</Row>
|
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
|
Net proceeds
|
|
<span className="text-gray-700">{formatMoney(netProceeds)}</span>
|
|
</Row>
|
|
</>
|
|
)}
|
|
</Col>
|
|
|
|
<Spacer h={8} />
|
|
|
|
<WarningConfirmationButton
|
|
marketType="binary"
|
|
amount={undefined}
|
|
warning={warning}
|
|
isSubmitting={isSubmitting}
|
|
onSubmit={betDisabled ? undefined : submitSell}
|
|
disabled={!!betDisabled}
|
|
size="xl"
|
|
color="blue"
|
|
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
|
|
/>
|
|
|
|
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
|
|
</>
|
|
)
|
|
}
|