manifold/web/components/contract/quick-bet-arrows.tsx
Ian Philips b39e0f304f
Yes and no buttons on contract page (#868)
* Yes and no buttons on contract page

* Cheating by adding 0.05 to max shares but gives better quickbet UX
2022-09-10 17:07:23 -06:00

360 lines
10 KiB
TypeScript

import clsx from 'clsx'
import {
getOutcomeProbability,
getOutcomeProbabilityAfterBet,
getProbability,
getTopAnswer,
} from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm'
import { User } from 'common/user'
import {
BinaryContract,
Contract,
NumericContract,
PseudoNumericContract,
resolution,
} from 'common/contract'
import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { placeBet } from 'web/lib/firebase/api'
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
import { Col } from '../layout/col'
import { OUTCOME_TO_COLOR } from '../outcome-label'
import { useSaveBinaryShares } from '../use-save-binary-shares'
import { sellShares } from 'web/lib/firebase/api'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { getBinaryProb } from 'common/contract-details'
const BET_SIZE = 10
export function QuickBetArrows(props: {
contract: BinaryContract | PseudoNumericContract
user: User
className?: string
}) {
const { contract, user, className } = props
const { mechanism, outcomeType } = contract
const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { hasYesShares, hasNoShares, yesShares, noShares } =
useSaveBinaryShares(contract, userBets)
const hasUpShares = hasYesShares
const hasDownShares = hasNoShares && !hasUpShares
const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = useState(false)
let previewProb = undefined
try {
previewProb = upHover
? getOutcomeProbabilityAfterBet(
contract,
quickOutcome(contract, 'UP') || '',
BET_SIZE
)
: downHover
? 1 -
getOutcomeProbabilityAfterBet(
contract,
quickOutcome(contract, 'DOWN') || '',
BET_SIZE
)
: undefined
} catch (e) {
// Catch any errors from hovering on an invalid option
}
let sharesSold: number | undefined
let sellOutcome: 'YES' | 'NO' | undefined
let saleAmount: number | undefined
if (isCpmm && (upHover || downHover)) {
const oppositeShares = upHover ? noShares : yesShares
if (oppositeShares) {
sellOutcome = upHover ? 'NO' : 'YES'
const prob = getProb(contract)
const maxSharesSold = BET_SIZE / (sellOutcome === 'YES' ? prob : 1 - prob)
sharesSold = Math.min(oppositeShares, maxSharesSold)
const { cpmmState, saleValue } = calculateCpmmSale(
contract,
sharesSold,
sellOutcome,
unfilledBets
)
saleAmount = saleValue
previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
}
}
async function placeQuickBet(direction: 'UP' | 'DOWN') {
const betPromise = async () => {
if (sharesSold && sellOutcome) {
return await sellShares({
shares: sharesSold,
outcome: sellOutcome,
contractId: contract.id,
})
}
const outcome = quickOutcome(contract, direction)
return await placeBet({
amount: BET_SIZE,
outcome,
contractId: contract.id,
})
}
const shortQ = contract.question.slice(0, 20)
const message =
sellOutcome && saleAmount
? `${formatMoney(saleAmount)} sold of "${shortQ}"...`
: `${formatMoney(BET_SIZE)} on "${shortQ}"...`
toast.promise(betPromise(), {
loading: message,
success: message,
error: (err) => `${err.message}`,
})
track('quick bet', {
slug: contract.slug,
direction,
contractId: contract.id,
})
}
return (
<Col
className={clsx(
className,
'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
// Use this for colored QuickBet panes
// `bg-opacity-10 bg-${color}`
)}
>
{/* Up bet triangle */}
<div>
<div
className="peer absolute top-0 left-0 right-0 h-[50%]"
onMouseEnter={() => setUpHover(true)}
onMouseLeave={() => setUpHover(false)}
onClick={() => placeQuickBet('UP')}
/>
<div className="mt-2 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(10)}
</div>
{hasUpShares ? (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? 'text-green-500' : 'text-gray-400'
)}
/>
) : (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? 'text-green-500' : 'text-gray-200'
)}
/>
)}
</div>
<QuickOutcomeView contract={contract} previewProb={previewProb} />
{/* Down bet triangle */}
{outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
<div>
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
<TriangleDownFillIcon
className={clsx('mx-auto h-5 w-5 text-gray-200')}
/>
</div>
) : (
<div>
<div
className="peer absolute bottom-0 left-0 right-0 h-[50%]"
onMouseEnter={() => setDownHover(true)}
onMouseLeave={() => setDownHover(false)}
onClick={() => placeQuickBet('DOWN')}
></div>
{hasDownShares ? (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5',
downHover ? 'text-red-500' : 'text-gray-400'
)}
/>
) : (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5',
downHover ? 'text-red-500' : 'text-gray-200'
)}
/>
)}
<div className="mb-2 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(10)}
</div>
</div>
)}
</Col>
)
}
export function ProbBar(props: { contract: Contract; previewProb?: number }) {
const { contract, previewProb } = props
const color = getColor(contract)
const prob = previewProb ?? getProb(contract)
return (
<>
<div
className={clsx(
'absolute right-0 top-0 w-1.5 rounded-tr-md transition-all',
'bg-gray-100'
)}
style={{ height: `${100 * (1 - prob)}%` }}
/>
<div
className={clsx(
'absolute right-0 bottom-0 w-1.5 rounded-br-md transition-all',
`bg-${color}`,
// If we're showing the full bar, also round the top
prob === 1 ? 'rounded-tr-md' : ''
)}
style={{ height: `${100 * prob}%` }}
/>
</>
)
}
export function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
const { outcomeType } = contract
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
function QuickOutcomeView(props: {
contract: Contract
previewProb?: number
caption?: 'chance' | 'expected'
}) {
const { contract, previewProb, caption } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
// If there's a preview prob, display that instead of the current prob
const override =
previewProb === undefined
? undefined
: isPseudoNumeric
? formatNumericProbability(previewProb, contract)
: formatPercent(previewProb)
const textColor = `text-${getColor(contract)}`
let display: string | undefined
switch (outcomeType) {
case 'BINARY':
display = getBinaryProbPercent(contract)
break
case 'PSEUDO_NUMERIC':
display = formatNumericProbability(getProbability(contract), contract)
break
case 'NUMERIC':
display = formatLargeNumber(getExpectedValue(contract))
break
case 'FREE_RESPONSE': {
const topAnswer = getTopAnswer(contract)
display =
topAnswer &&
formatPercent(getOutcomeProbability(contract, topAnswer.id))
break
}
}
return (
<Col className={clsx('items-center text-3xl', textColor)}>
{override ?? display}
{caption && <div className="text-base">{caption}</div>}
<ProbBar contract={contract} previewProb={previewProb} />
</Col>
)
}
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) {
const { outcomeType, resolution, resolutionProbability } = contract
return resolutionProbability
? resolutionProbability
: resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'PSEUDO_NUMERIC'
? getProbability(contract)
: outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC'
? getNumericScale(contract)
: 1 // Should not happen
}
function getNumericScale(contract: NumericContract) {
const { min, max } = contract
const ev = getExpectedValue(contract)
return (ev - min) / (max - min)
}
export function getColor(contract: Contract) {
// TODO: Try injecting a gradient here
// return 'primary'
const { resolution, outcomeType } = contract
if (resolution) {
return (
OUTCOME_TO_COLOR[resolution as resolution] ??
// If resolved to a FR answer, use 'primary'
'primary'
)
}
if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400'
if ((contract.closeTime ?? Infinity) < Date.now()) {
return 'gray-400'
}
// TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
return 'primary'
}