diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index c4b33146..2e935b66 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import _ from 'lodash' import { useUser } from '../hooks/use-user' -import { formatMoney } from '../../common/util/format' +import { formatMoney, formatWithCommas } from '../../common/util/format' import { Col } from './layout/col' import { Row } from './layout/row' import { Bet, MAX_LOAN_PER_CONTRACT } from '../../common/bet' @@ -86,7 +86,7 @@ export function BuyAmountInput(props: { error: string | undefined setError: (error: string | undefined) => void contractIdForLoan: string | undefined - userBets: Bet[] + userBets?: Bet[] minimumAmount?: number disabled?: boolean className?: string @@ -110,7 +110,9 @@ export function BuyAmountInput(props: { const user = useUser() - const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) + const openUserBets = (userBets ?? []).filter( + (bet) => !bet.isSold && !bet.sale + ) const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0) const loanAmount = contractIdForLoan @@ -182,7 +184,6 @@ export function SellAmountInput(props: { userBets: Bet[] error: string | undefined setError: (error: string | undefined) => void - minimumAmount?: number disabled?: boolean className?: string inputClassName?: string @@ -199,7 +200,6 @@ export function SellAmountInput(props: { disabled, className, inputClassName, - minimumAmount, inputRef, } = props @@ -216,6 +216,7 @@ export function SellAmountInput(props: { ] const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + const shares = yesShares || noShares const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0) @@ -227,10 +228,24 @@ export function SellAmountInput(props: { const loanRepaid = Math.min(prevLoanAmount, sellAmount) + const onAmountChange = (amount: number | undefined) => { + onChange(amount) + + // Check for errors. + if (amount !== undefined) { + console.log(shares, amount) + if (amount > shares) { + setError(`Maximum ${formatWithCommas(Math.floor(shares))} shares`) + } else { + setError(undefined) + } + } + } + return ( - Sale amount{' '} + Sale proceeds{' '} {formatMoney(sellAmount)} {prevLoanAmount && ( diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 8b223768..16dda684 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -14,7 +14,7 @@ import { formatWithCommas, } from '../../common/util/format' import { Title } from './title' -import { firebaseLogin } from '../lib/firebase/users' +import { firebaseLogin, User } from '../lib/firebase/users' import { Bet } from '../../common/bet' import { placeBet } from '../lib/firebase/api-call' import { BuyAmountInput, SellAmountInput } from './amount-input' @@ -28,6 +28,10 @@ import { } from '../../common/calculate' import { useFocus } from '../hooks/use-focus' import { useUserContractBets } from '../hooks/use-user-bets' +import { + calculateCpmmSale, + getCpmmProbability, +} from '../../common/calculate-cpmm' export function BetPanel(props: { contract: FullContract @@ -36,15 +40,13 @@ export function BetPanel(props: { selected?: 'YES' | 'NO' onBetSuccess?: () => void }) { - useEffect(() => { - // warm up cloud function - placeBet({}).catch() - }, []) - const { contract, className, title, selected, onBetSuccess } = props const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) ?? [] + + const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY') + const [yesBets, noBets] = _.partition( userBets, (bet) => bet.outcome === 'YES' @@ -54,9 +56,93 @@ export function BetPanel(props: { _.sumBy(noBets, (bet) => bet.shares), ] - const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + + return ( + + {sharesOutcome && ( + + +
+ You have {formatWithCommas(Math.floor(yesShares || noShares))}{' '} + shares +
+ + +
+ + )} + + + + + {tradeType === 'SELL' && user && sharesOutcome && ( + <SellPanel + contract={contract as FullContract<CPMM, Binary>} + shares={yesShares || noShares} + sharesOutcome={sharesOutcome} + user={user} + userBets={userBets} + onSellSuccess={onBetSuccess} + /> + )} + + {tradeType === 'BUY' && ( + <BuyPanel + contract={contract} + user={user} + userBets={userBets} + selected={selected} + onBuySuccess={onBetSuccess} + /> + )} + + {user === null && ( + <button + className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" + onClick={firebaseLogin} + > + Sign in to trade! + </button> + )} + </Col> + </Col> + ) +} + +function BuyPanel(props: { + contract: FullContract<DPM | CPMM, Binary> + user: User | null | undefined + userBets: Bet[] + selected?: 'YES' | 'NO' + onBuySuccess?: () => void +}) { + const { contract, user, userBets, selected, onBuySuccess } = props - const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY') const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [inputRef, focusAmountInput] = useFocus() @@ -65,6 +151,11 @@ export function BetPanel(props: { const [isSubmitting, setIsSubmitting] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) + useEffect(() => { + // warm up cloud function + placeBet({}).catch() + }, []) + function onBetChoice(choice: 'YES' | 'NO') { setBetChoice(choice) setWasSubmitted(false) @@ -97,7 +188,7 @@ export function BetPanel(props: { setIsSubmitting(false) setWasSubmitted(true) setBetAmount(undefined) - if (onBetSuccess) onBetSuccess() + if (onBuySuccess) onBuySuccess() } else { setError(result?.error || 'Error placing bet') setIsSubmitting(false) @@ -128,10 +219,9 @@ export function BetPanel(props: { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) - const panelTitle = title ?? 'Place a trade' - if (title) { + useEffect(() => { focusAmountInput() - } + }, [focusAmountInput]) const tooltip = contract.mechanism === 'dpm-2' @@ -143,84 +233,25 @@ export function BetPanel(props: { : 0) )} ${betChoice} shares` : undefined - return ( - <Col className={clsx('rounded-md bg-white px-8 py-6', className)}> - <Title - className={clsx('!mt-0', title ? '!text-xl' : '')} - text={panelTitle} + <> + <YesNoSelector + className="mb-4" + selected={betChoice} + onSelect={(choice) => onBetChoice(choice)} + /> + <div className="my-3 text-left text-sm text-gray-500">Amount</div> + <BuyAmountInput + inputClassName="w-full" + amount={betAmount} + onChange={onBetChange} + userBets={userBets} + error={error} + setError={setError} + disabled={isSubmitting} + inputRef={inputRef} + contractIdForLoan={contract.id} /> - - {contract.mechanism === 'cpmm-1' && ( - <Row className="gap-2 w-full tabs mb-6"> - <div - className={clsx( - 'tab gap-2 tab-bordered flex-1', - tradeType === 'BUY' && 'tab-active' - )} - onClick={() => setTradeType('BUY')} - > - BUY - </div> - <div - className={clsx( - 'tab gap-2 tab-bordered flex-1', - tradeType === 'SELL' && 'tab-active' - )} - onClick={() => setTradeType('SELL')} - > - SELL - </div> - </Row> - )} - - {tradeType === 'BUY' ? ( - <> - <YesNoSelector - className="mb-4" - selected={betChoice} - onSelect={(choice) => onBetChoice(choice)} - /> - <div className="my-3 text-left text-sm text-gray-500">Buy amount</div> - <BuyAmountInput - inputClassName="w-full" - amount={betAmount} - onChange={onBetChange} - userBets={userBets} - error={error} - setError={setError} - disabled={isSubmitting} - inputRef={inputRef} - contractIdForLoan={contract.id} - /> - </> - ) : sellOutcome ? ( - <> - <div className="mb-3 text-left "> - You have {formatWithCommas(yesShares || noShares)}{' '} - <OutcomeLabel outcome={sellOutcome} /> shares - </div> - - <div className="my-3 text-left text-sm text-gray-500"> - Sell quantity - </div> - <SellAmountInput - inputClassName="w-full" - contract={contract as FullContract<CPMM, Binary>} - amount={betAmount} - onChange={onBetChange} - userBets={userBets} - error={error} - setError={setError} - disabled={isSubmitting} - inputRef={inputRef} - /> - </> - ) : ( - <div className="mb-3 text-left text-gray-500"> - You have don't have any shares to sell. - </div> - )} <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> @@ -232,23 +263,21 @@ export function BetPanel(props: { </Row> </Row> - {tradeType === 'BUY' && ( - <Row className="items-start justify-between gap-2 text-sm"> - <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> - <div> - Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} /> - </div> + <Row className="items-start justify-between gap-2 text-sm"> + <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> + <div> + Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} /> + </div> - {tooltip && <InfoTooltip text={tooltip} />} - </Row> - <Row className="flex-wrap items-end justify-end gap-2"> - <span className="whitespace-nowrap"> - {formatMoney(currentPayout)} - </span> - <span>(+{currentReturnPercent})</span> - </Row> + {tooltip && <InfoTooltip text={tooltip} />} </Row> - )} + <Row className="flex-wrap items-end justify-end gap-2"> + <span className="whitespace-nowrap"> + {formatMoney(currentPayout)} + </span> + <span>(+{currentReturnPercent})</span> + </Row> + </Row> </Col> <Spacer h={8} /> @@ -266,19 +295,107 @@ export function BetPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit trade'} - </button> - )} - {user === null && ( - <button - className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" - onClick={firebaseLogin} - > - Sign in to trade! + {isSubmitting ? 'Submitting...' : 'Submit Buy'} </button> )} - {wasSubmitted && <div className="mt-4">Trade submitted!</div>} - </Col> + {wasSubmitted && <div className="mt-4">Buy submitted!</div>} + </> + ) +} + +function SellPanel(props: { + contract: FullContract<CPMM, Binary> + 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>(Math.floor(shares)) + const [error, setError] = useState<string | undefined>() + const [isSubmitting, setIsSubmitting] = useState(false) + const [wasSubmitted, setWasSubmitted] = useState(false) + + const betDisabled = isSubmitting || !amount || error + + async function submitSell() { + if (!user || !amount) return + + setError(undefined) + setIsSubmitting(true) + + const result = await placeBet({ + shares: amount, + outcome: sharesOutcome, + contractId: contract.id, + }).then((r) => r.data as any) + + console.log('placed bet. Result:', result) + + if (result?.status === 'success') { + setIsSubmitting(false) + setWasSubmitted(true) + setAmount(undefined) + if (onSellSuccess) onSellSuccess() + } else { + setError(result?.error || 'Error selling') + setIsSubmitting(false) + } + } + + const initialProb = getProbability(contract) + const { newPool } = calculateCpmmSale(contract, { + shares: amount ?? 0, + outcome: sharesOutcome, + } as Bet) + const resultProb = getCpmmProbability(newPool, contract.p) + + return ( + <> + <SellAmountInput + inputClassName="w-full" + contract={contract} + amount={amount} + onChange={setAmount} + userBets={userBets} + error={error} + setError={setError} + disabled={isSubmitting} + /> + + <Col className="mt-3 w-full gap-3"> + <Row className="items-center justify-between text-sm"> + <div className="text-gray-500">Probability</div> + <Row> + <div>{formatPercent(initialProb)}</div> + <div className="mx-2">→</div> + <div>{formatPercent(resultProb)}</div> + </Row> + </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>} + </> ) } diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index f766e51d..1508d0cd 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -28,6 +28,7 @@ export default function BetRow(props: { </div> <YesNoSelector btnClassName="btn-sm w-20" + showBuyLabel onSelect={(choice) => { setOpen(true) setBetChoice(choice) diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index f1d086d1..674a2293 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -7,10 +7,11 @@ import { Row } from './layout/row' export function YesNoSelector(props: { selected?: 'YES' | 'NO' onSelect: (selected: 'YES' | 'NO') => void + showBuyLabel?: boolean className?: string btnClassName?: string }) { - const { selected, onSelect, className, btnClassName } = props + const { selected, onSelect, showBuyLabel, className, btnClassName } = props const commonClassNames = 'inline-flex flex-1 items-center justify-center rounded-3xl border-2 p-2' @@ -28,7 +29,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('YES')} > - YES + {showBuyLabel ? 'Buy' : ''} YES </button> <button className={clsx( @@ -41,7 +42,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('NO')} > - NO + {showBuyLabel ? 'Buy' : ''} NO </button> </Row> )