🏦 Per-market loans! (#57)

* Loan backend: Add loanAmount field to Bet, manage loans up to max loan amount per market -- buy, sell, and resolve.

* Loan frontend: show your loan amount in bet panel, answer bet panel

* Resolve emails include full payout not subtracting loan

* Exclude sold bets from current loan amount

* Handle bets table for loans. Sell dialog explains how you will repay your loan.

* Floor remaining balance

* Fix layout of create answer bet info

* Clean up Sell popup UI

* Fix bug where listen query was not updating data.

* Reword loan copy

* Adjust bet panel width

* Fix loan calc on front end

* Add comment for includeMetadataChanges.

Co-authored-by: Austin Chen <akrolsmir@gmail.com>
This commit is contained in:
James Grugett 2022-03-01 21:31:48 -06:00 committed by GitHub
parent a3973b3481
commit 985cdd2537
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 291 additions and 116 deletions

View File

@ -4,6 +4,7 @@ export type Bet = {
contractId: string contractId: string
amount: number // bet size; negative if SELL bet amount: number // bet size; negative if SELL bet
loanAmount?: number
outcome: string outcome: string
shares: number // dynamic parimutuel pool weight; negative if SELL bet shares: number // dynamic parimutuel pool weight; negative if SELL bet
@ -21,3 +22,5 @@ export type Bet = {
createdTime: number createdTime: number
} }
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -1,4 +1,5 @@
import { Bet } from './bet' import * as _ from 'lodash'
import { Bet, MAX_LOAN_PER_CONTRACT } from './bet'
import { import {
calculateShares, calculateShares,
getProbability, getProbability,
@ -11,6 +12,7 @@ export const getNewBinaryBetInfo = (
user: User, user: User,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
loanAmount: number,
contract: Contract, contract: Contract,
newBetId: string newBetId: string
) => { ) => {
@ -45,6 +47,7 @@ export const getNewBinaryBetInfo = (
userId: user.id, userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -52,7 +55,7 @@ export const getNewBinaryBetInfo = (
createdTime: Date.now(), createdTime: Date.now(),
} }
const newBalance = user.balance - amount const newBalance = user.balance - (amount - loanAmount)
return { newBet, newPool, newTotalShares, newTotalBets, newBalance } return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
} }
@ -61,6 +64,7 @@ export const getNewMultiBetInfo = (
user: User, user: User,
outcome: string, outcome: string,
amount: number, amount: number,
loanAmount: number,
contract: Contract, contract: Contract,
newBetId: string newBetId: string
) => { ) => {
@ -85,6 +89,7 @@ export const getNewMultiBetInfo = (
userId: user.id, userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -92,7 +97,17 @@ export const getNewMultiBetInfo = (
createdTime: Date.now(), createdTime: Date.now(),
} }
const newBalance = user.balance - amount const newBalance = user.balance - (amount - loanAmount)
return { newBet, newPool, newTotalShares, newTotalBets, newBalance } return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
} }
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = _.sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
return loanAmount
}

View File

@ -161,3 +161,12 @@ export const getPayoutsMultiOutcome = (
.map(({ userId, payout }) => ({ userId, payout })) .map(({ userId, payout }) => ({ userId, payout }))
.concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee .concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee
} }
export const getLoanPayouts = (bets: Bet[]) => {
const betsWithLoans = bets.filter((bet) => bet.loanAmount)
const betsByUser = _.groupBy(betsWithLoans, (bet) => bet.userId)
const loansByUser = _.mapValues(betsByUser, (bets) =>
_.sumBy(bets, (bet) => -(bet.loanAmount ?? 0))
)
return _.toPairs(loansByUser).map(([userId, payout]) => ({ userId, payout }))
}

View File

@ -11,7 +11,7 @@ export const getSellBetInfo = (
newBetId: string newBetId: string
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet const { id: betId, amount, shares, outcome, loanAmount } = bet
const adjShareValue = calculateShareValue(contract, bet) const adjShareValue = calculateShareValue(contract, bet)
@ -57,7 +57,7 @@ export const getSellBetInfo = (
}, },
} }
const newBalance = user.balance + saleAmount const newBalance = user.balance + saleAmount - (loanAmount ?? 0)
return { return {
newBet, newBet,

View File

@ -3,10 +3,11 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { getNewMultiBetInfo } from '../../common/new-bet' import { getLoanAmount, getNewMultiBetInfo } from '../../common/new-bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getContract, getValues } from './utils' import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails' import { sendNewAnswerEmail } from './emails'
import { Bet } from '../../common/bet'
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
async ( async (
@ -55,6 +56,11 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const [lastAnswer] = await getValues<Answer>( const [lastAnswer] = await getValues<Answer>(
firestore firestore
.collection(`contracts/${contractId}/answers`) .collection(`contracts/${contractId}/answers`)
@ -92,8 +98,17 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
.collection(`contracts/${contractId}/bets`) .collection(`contracts/${contractId}/bets`)
.doc() .doc()
const loanAmount = getLoanAmount(yourBets, amount)
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id) getNewMultiBetInfo(
user,
answerId,
amount,
loanAmount,
contract,
newBetDoc.id
)
transaction.create(newBetDoc, newBet) transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, { transaction.update(contractDoc, {

View File

@ -3,7 +3,13 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet' import {
getLoanAmount,
getNewBinaryBetInfo,
getNewMultiBetInfo,
} from '../../common/new-bet'
import { Bet } from '../../common/bet'
import { getValues } from './utils'
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
async ( async (
@ -46,6 +52,11 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
if (outcomeType === 'FREE_RESPONSE') { if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get( const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome) contractDoc.collection('answers').doc(outcome)
@ -58,16 +69,26 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
.collection(`contracts/${contractId}/bets`) .collection(`contracts/${contractId}/bets`)
.doc() .doc()
const loanAmount = getLoanAmount(yourBets, amount)
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? getNewBinaryBetInfo( ? getNewBinaryBetInfo(
user, user,
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
amount, amount,
loanAmount,
contract,
newBetDoc.id
)
: getNewMultiBetInfo(
user,
outcome,
amount,
loanAmount,
contract, contract,
newBetDoc.id newBetDoc.id
) )
: getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id)
transaction.create(newBetDoc, newBet) transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, { transaction.update(contractDoc, {

View File

@ -7,7 +7,11 @@ import { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, payUser } from './utils' import { getUser, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails' import { sendMarketResolutionEmail } from './emails'
import { getPayouts, getPayoutsMultiOutcome } from '../../common/payouts' import {
getLoanPayouts,
getPayouts,
getPayoutsMultiOutcome,
} from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
export const resolveMarket = functions export const resolveMarket = functions
@ -99,13 +103,23 @@ export const resolveMarket = functions
? getPayoutsMultiOutcome(resolutions, contract, openBets) ? getPayoutsMultiOutcome(resolutions, contract, openBets)
: getPayouts(outcome, contract, openBets, resolutionProbability) : getPayouts(outcome, contract, openBets, resolutionProbability)
const loanPayouts = getLoanPayouts(openBets)
console.log('payouts:', payouts) console.log('payouts:', payouts)
const groups = _.groupBy(payouts, (payout) => payout.userId) const groups = _.groupBy(
[...payouts, ...loanPayouts],
(payout) => payout.userId
)
const userPayouts = _.mapValues(groups, (group) => const userPayouts = _.mapValues(groups, (group) =>
_.sumBy(group, (g) => g.payout) _.sumBy(group, (g) => g.payout)
) )
const groupsWithoutLoans = _.groupBy(payouts, (payout) => payout.userId)
const userPayoutsWithoutLoans = _.mapValues(groupsWithoutLoans, (group) =>
_.sumBy(group, (g) => g.payout)
)
const payoutPromises = Object.entries(userPayouts).map( const payoutPromises = Object.entries(userPayouts).map(
([userId, payout]) => payUser(userId, payout) ([userId, payout]) => payUser(userId, payout)
) )
@ -116,7 +130,7 @@ export const resolveMarket = functions
await sendResolutionEmails( await sendResolutionEmails(
openBets, openBets,
userPayouts, userPayoutsWithoutLoans,
creator, creator,
contract, contract,
outcome, outcome,

View File

@ -1,15 +1,20 @@
import clsx from 'clsx' import clsx from 'clsx'
import _ from 'lodash'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { formatMoney } from '../../common/util/format' import { formatMoney } from '../../common/util/format'
import { AddFundsButton } from './add-funds-button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useUserContractBets } from '../hooks/use-user-bets'
import { MAX_LOAN_PER_CONTRACT } from '../../common/bet'
import { InfoTooltip } from './info-tooltip'
import { Spacer } from './layout/spacer'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
onChange: (newAmount: number | undefined) => void onChange: (newAmount: number | undefined) => void
error: string | undefined error: string | undefined
setError: (error: string | undefined) => void setError: (error: string | undefined) => void
contractId: string | undefined
minimumAmount?: number minimumAmount?: number
disabled?: boolean disabled?: boolean
className?: string className?: string
@ -22,6 +27,7 @@ export function AmountInput(props: {
onChange, onChange,
error, error,
setError, setError,
contractId,
disabled, disabled,
className, className,
inputClassName, inputClassName,
@ -31,10 +37,24 @@ export function AmountInput(props: {
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contractId) ?? []
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
amount ?? 0,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
const onAmountChange = (str: string) => { const onAmountChange = (str: string) => {
if (str.includes('-')) {
onChange(undefined)
return
}
const amount = parseInt(str.replace(/[^\d]/, '')) const amount = parseInt(str.replace(/[^\d]/, ''))
if (str && isNaN(amount)) return if (str && isNaN(amount)) return
if (amount >= 10 ** 9) return
onChange(str ? amount : undefined) onChange(str ? amount : undefined)
@ -47,7 +67,8 @@ export function AmountInput(props: {
} }
} }
const remainingBalance = Math.max(0, (user?.balance ?? 0) - (amount ?? 0)) const amountNetLoan = (amount ?? 0) - loanAmount
const remainingBalance = Math.max(0, (user?.balance ?? 0) - amountNetLoan)
return ( return (
<Col className={className}> <Col className={className}>
@ -68,19 +89,34 @@ export function AmountInput(props: {
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}
/> />
</label> </label>
<Spacer h={4} />
{error && ( {error && (
<div className="mr-auto mt-4 self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error} {error}
</div> </div>
)} )}
{user && ( {user && (
<Col className="mt-3 text-sm"> <Col className="gap-3 text-sm">
<div className="mb-2 whitespace-nowrap text-gray-500"> {contractId && (
Remaining balance <Row className="items-center justify-between gap-2 text-gray-500">
</div> <Row className="items-center gap-2">
<Row className="gap-4"> Amount loaned{' '}
<div>{formatMoney(Math.floor(remainingBalance))}</div> <InfoTooltip
{user.balance !== 1000 && <AddFundsButton />} text={`In every market, you get an interest-free loan on the first ${formatMoney(
MAX_LOAN_PER_CONTRACT
)}.`}
/>
</Row>
<span className="text-neutral">{formatMoney(loanAmount)}</span>{' '}
</Row>
)}
<Row className="items-center justify-between gap-2 text-gray-500">
Remaining balance{' '}
<span className="text-neutral">
{formatMoney(Math.floor(remainingBalance))}
</span>
</Row> </Row>
</Col> </Col>
)} )}

View File

@ -30,8 +30,9 @@ export function AnswerBetPanel(props: {
answer: Answer answer: Answer
contract: Contract contract: Contract
closePanel: () => void closePanel: () => void
className?: string
}) { }) {
const { answer, contract, closePanel } = props const { answer, contract, closePanel, className } = props
const { id: answerId } = answer const { id: answerId } = answer
const user = useUser() const user = useUser()
@ -97,7 +98,7 @@ export function AnswerBetPanel(props: {
const currentReturnPercent = (currentReturn * 100).toFixed() + '%' const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
return ( return (
<Col className="items-start px-2 pb-2 pt-4 sm:pt-0"> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="self-stretch items-center justify-between"> <Row className="self-stretch items-center justify-between">
<div className="text-xl">Buy this answer</div> <div className="text-xl">Buy this answer</div>
@ -114,40 +115,44 @@ export function AnswerBetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
contractId={contract.id}
/> />
<Col className="gap-3 mt-3 w-full">
<Row className="justify-between items-center 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>
<Spacer h={4} /> <Row className="justify-between items-start text-sm gap-2">
<Row className="flex-nowrap whitespace-nowrap items-center gap-2 text-gray-500">
<div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div> <div>Payout if chosen</div>
<Row> <InfoTooltip
<div>{formatPercent(initialProb)}</div> text={`Current payout for ${formatWithCommas(
<div className="mx-2"></div> shares
<div>{formatPercent(resultProb)}</div> )} / ${formatWithCommas(
</Row> shares + contract.totalShares[answerId]
)} shares`}
<Spacer h={4} /> />
</Row>
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> <Row className="flex-wrap justify-end items-end gap-2">
Payout if chosen <span className="whitespace-nowrap">
<InfoTooltip {formatMoney(currentPayout)}
text={`Current payout for ${formatWithCommas( </span>
shares <span>(+{currentReturnPercent})</span>
)} / ${formatWithCommas( </Row>
shares + contract.totalShares[answerId] </Row>
)} shares`} </Col>
/>
</Row>
<div>
{formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span>
</div>
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<button <button
className={clsx( className={clsx(
'btn', 'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary', betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : '' isSubmitting ? 'loading' : ''
)} )}
@ -157,7 +162,7 @@ export function AnswerBetPanel(props: {
</button> </button>
) : ( ) : (
<button <button
className="btn mt-4 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" className="btn self-stretch 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} onClick={firebaseLogin}
> >
Sign in to trade! Sign in to trade!

View File

@ -97,6 +97,7 @@ export function AnswerItem(props: {
answer={answer} answer={answer}
contract={contract} contract={contract}
closePanel={() => setIsBetting(false)} closePanel={() => setIsBetting(false)}
className="sm:w-72"
/> />
) : ( ) : (
<Row className="items-center justify-end gap-4 self-end sm:self-start"> <Row className="items-center justify-end gap-4 self-end sm:self-start">

View File

@ -86,7 +86,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) {
<div /> <div />
<Col <Col
className={clsx( className={clsx(
'sm:flex-row gap-4', 'sm:flex-row sm:items-end gap-4',
text ? 'justify-between' : 'self-end' text ? 'justify-between' : 'self-end'
)} )}
> >
@ -101,34 +101,42 @@ export function CreateAnswerPanel(props: { contract: Contract }) {
setError={setAmountError} setError={setAmountError}
minimumAmount={1} minimumAmount={1}
disabled={isSubmitting} disabled={isSubmitting}
contractId={contract.id}
/> />
</Col> </Col>
<Col className="gap-2 mt-1"> <Col className="gap-3">
<div className="text-sm text-gray-500">Implied probability</div> <Row className="justify-between items-center text-sm">
<Row> <div className="text-gray-500">Probability</div>
<div>{formatPercent(0)}</div> <Row>
<div className="mx-2"></div> <div>{formatPercent(0)}</div>
<div>{formatPercent(resultProb)}</div> <div className="mx-2"></div>
<div>{formatPercent(resultProb)}</div>
</Row>
</Row> </Row>
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
Payout if chosen <Row className="justify-between text-sm gap-2">
<InfoTooltip <Row className="flex-nowrap whitespace-nowrap items-center gap-2 text-gray-500">
text={`Current payout for ${formatWithCommas( <div>Payout if chosen</div>
shares <InfoTooltip
)} / ${formatWithCommas(shares)} shares`} text={`Current payout for ${formatWithCommas(
/> shares
)} / ${formatWithCommas(shares)} shares`}
/>
</Row>
<Row className="flex-wrap justify-end items-end gap-2">
<span className="whitespace-nowrap">
{formatMoney(currentPayout)}
</span>
<span>(+{currentReturnPercent})</span>
</Row>
</Row> </Row>
<div>
{formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span>
</div>
</Col> </Col>
</> </>
)} )}
{user ? ( {user ? (
<button <button
className={clsx( className={clsx(
'btn self-end mt-2', 'btn mt-2',
canSubmit ? 'btn-outline' : 'btn-disabled', canSubmit ? 'btn-outline' : 'btn-disabled',
isSubmitting && 'loading' isSubmitting && 'loading'
)} )}

View File

@ -144,7 +144,6 @@ export function BetPanel(props: {
text={panelTitle} text={panelTitle}
/> />
{/* <div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div> */}
<YesNoSelector <YesNoSelector
className="mb-4" className="mb-4"
selected={betChoice} selected={betChoice}
@ -160,45 +159,49 @@ export function BetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
contractId={contract.id}
/> />
<Spacer h={4} /> <Col className="gap-3 mt-3 w-full">
<Row className="justify-between items-center 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>
<div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div> <Row className="justify-between items-start text-sm gap-2">
<Row> <Row className="flex-nowrap whitespace-nowrap items-center gap-2 text-gray-500">
<div>{formatPercent(initialProb)}</div> <div>
<div className="mx-2"></div> Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
<div>{formatPercent(resultProb)}</div> </div>
</Row>
{betChoice && (
<>
<Spacer h={4} />
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
Payout if <OutcomeLabel outcome={betChoice} />
<InfoTooltip <InfoTooltip
text={`Current payout for ${formatWithCommas( text={`Current payout for ${formatWithCommas(
shares shares
)} / ${formatWithCommas( )} / ${formatWithCommas(
shares + shares +
totalShares[betChoice] - totalShares[betChoice ?? 'YES'] -
(phantomShares ? phantomShares[betChoice] : 0) (phantomShares ? phantomShares[betChoice ?? 'YES'] : 0)
)} ${betChoice} shares`} )} ${betChoice} shares`}
/> />
</Row> </Row>
<div> <Row className="flex-wrap justify-end items-end gap-2">
{formatMoney(currentPayout)} <span className="whitespace-nowrap">
&nbsp; <span>(+{currentReturnPercent})</span> {formatMoney(currentPayout)}
</div> </span>
</> <span>(+{currentReturnPercent})</span>
)} </Row>
</Row>
</Col>
<Spacer h={6} /> <Spacer h={8} />
{user && ( {user && (
<button <button
className={clsx( className={clsx(
'btn', 'btn flex-1',
betDisabled betDisabled
? 'btn-disabled' ? 'btn-disabled'
: betChoice === 'YES' : betChoice === 'YES'
@ -213,7 +216,7 @@ export function BetPanel(props: {
)} )}
{user === null && ( {user === null && (
<button <button
className="btn mt-4 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" 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} onClick={firebaseLogin}
> >
Sign in to trade! Sign in to trade!

View File

@ -457,6 +457,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
shares, shares,
isSold, isSold,
isAnte, isAnte,
loanAmount,
} = bet } = bet
const { isResolved, closeTime } = contract const { isResolved, closeTime } = contract
@ -464,7 +465,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
const saleAmount = saleBet?.sale?.amount const saleAmount = saleBet?.sale?.amount
const saleDisplay = bet.isAnte ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
) : saleAmount !== undefined ? ( ) : saleAmount !== undefined ? (
<>{formatMoney(saleAmount)} (sold)</> <>{formatMoney(saleAmount)} (sold)</>
@ -491,7 +492,10 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
<td> <td>
<OutcomeLabel outcome={outcome} /> <OutcomeLabel outcome={outcome} />
</td> </td>
<td>{formatMoney(amount)}</td> <td>
{formatMoney(amount)}
{loanAmount ? ` (${formatMoney(loanAmount ?? 0)} loan)` : ''}
</td>
<td>{saleDisplay}</td> <td>{saleDisplay}</td>
{!isResolved && <td>{payoutIfChosenDisplay}</td>} {!isResolved && <td>{payoutIfChosenDisplay}</td>}
<td> <td>
@ -510,18 +514,19 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
}, []) }, [])
const { contract, bet } = props const { contract, bet } = props
const isBinary = contract.outcomeType === 'BINARY' const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const initialProb = getOutcomeProbability( const initialProb = getOutcomeProbability(
contract.totalShares, contract.totalShares,
bet.outcome === 'NO' ? 'YES' : bet.outcome outcome === 'NO' ? 'YES' : outcome
) )
const outcomeProb = getProbabilityAfterSale( const outcomeProb = getProbabilityAfterSale(
contract.totalShares, contract.totalShares,
bet.outcome, outcome,
bet.shares shares
) )
const saleAmount = calculateSaleAmount(contract, bet) const saleAmount = calculateSaleAmount(contract, bet)
@ -533,7 +538,7 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell', label: 'Sell',
}} }}
submitBtn={{ className: 'btn-primary' }} submitBtn={{ className: 'btn-primary', label: 'Sell' }}
onSubmit={async () => { onSubmit={async () => {
setIsSubmitting(true) setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id }) await sellBet({ contractId: contract.id, betId: bet.id })
@ -541,17 +546,19 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
}} }}
> >
<div className="mb-4 text-2xl"> <div className="mb-4 text-2xl">
Sell <OutcomeLabel outcome={bet.outcome} /> Sell {formatWithCommas(shares)} shares of{' '}
</div> <OutcomeLabel outcome={outcome} /> for {formatMoney(saleAmount)}?
<div>
Do you want to sell {formatWithCommas(bet.shares)} shares of{' '}
<OutcomeLabel outcome={bet.outcome} /> for {formatMoney(saleAmount)}?
</div> </div>
{!!loanAmount && (
<div className="mt-2">
You will also pay back {formatMoney(loanAmount)} of your loan, for a
net of {formatMoney(saleAmount - loanAmount)}.
</div>
)}
<div className="mt-2 mb-1 text-sm text-gray-500"> <div className="mt-2 mb-1 text-sm">
({isBinary ? 'Updated' : <OutcomeLabel outcome={bet.outcome} />}{' '} Market probability: {formatPercent(initialProb)} {' '}
probability: {formatPercent(initialProb)} {formatPercent(outcomeProb)} {formatPercent(outcomeProb)}
)
</div> </div>
</ConfirmationButton> </ConfirmationButton>
) )

View File

@ -1,6 +1,10 @@
import _ from 'lodash' import _ from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Bet, listenForUserBets } from '../lib/firebase/bets' import {
Bet,
listenForUserBets,
listenForUserContractBets,
} from '../lib/firebase/bets'
export const useUserBets = (userId: string | undefined) => { export const useUserBets = (userId: string | undefined) => {
const [bets, setBets] = useState<Bet[] | undefined>(undefined) const [bets, setBets] = useState<Bet[] | undefined>(undefined)
@ -12,6 +16,20 @@ export const useUserBets = (userId: string | undefined) => {
return bets return bets
} }
export const useUserContractBets = (
userId: string | undefined,
contractId: string | undefined
) => {
const [bets, setBets] = useState<Bet[] | undefined>(undefined)
useEffect(() => {
if (userId && contractId)
return listenForUserContractBets(userId, contractId, setBets)
}, [userId, contractId])
return bets
}
export const useUserBetContracts = (userId: string | undefined) => { export const useUserBetContracts = (userId: string | undefined) => {
const [contractIds, setContractIds] = useState<string[] | undefined>() const [contractIds, setContractIds] = useState<string[] | undefined>()

View File

@ -74,6 +74,21 @@ export function listenForUserBets(
}) })
} }
export function listenForUserContractBets(
userId: string,
contractId: string,
setBets: (bets: Bet[]) => void
) {
const betsQuery = query(
collection(db, 'contracts', contractId, 'bets'),
where('userId', '==', userId)
)
return listenForValues<Bet>(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

@ -1,6 +1,5 @@
import { db } from './init' import { db } from './init'
import { import {
doc,
getDoc, getDoc,
getDocs, getDocs,
onSnapshot, onSnapshot,
@ -22,7 +21,9 @@ export function listenForValue<T>(
docRef: DocumentReference, docRef: DocumentReference,
setValue: (value: T | null) => void setValue: (value: T | null) => void
) { ) {
return onSnapshot(docRef, (snapshot) => { // Exclude cached snapshots so we only trigger on fresh data.
// includeMetadataChanges ensures listener is called even when server data is the same as cached data.
return onSnapshot(docRef, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return if (snapshot.metadata.fromCache) return
const value = snapshot.exists() ? (snapshot.data() as T) : null const value = snapshot.exists() ? (snapshot.data() as T) : null
@ -34,7 +35,9 @@ export function listenForValues<T>(
query: Query, query: Query,
setValues: (values: T[]) => void setValues: (values: T[]) => void
) { ) {
return onSnapshot(query, (snapshot) => { // Exclude cached snapshots so we only trigger on fresh data.
// includeMetadataChanges ensures listener is called even when server data is the same as cached data.
return onSnapshot(query, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return if (snapshot.metadata.fromCache) return
const values = snapshot.docs.map((doc) => doc.data() as T) const values = snapshot.docs.map((doc) => doc.data() as T)

View File

@ -145,7 +145,7 @@ export default function ContractPage(props: {
<Col className="flex-1"> <Col className="flex-1">
{allowTrade && ( {allowTrade && (
<BetPanel className="hidden lg:inline" contract={contract} /> <BetPanel className="hidden lg:flex" contract={contract} />
)} )}
{allowResolve && ( {allowResolve && (
<ResolutionPanel creator={user} contract={contract} /> <ResolutionPanel creator={user} contract={contract} />

View File

@ -248,6 +248,7 @@ export function NewContract(props: { question: string; tag?: string }) {
error={anteError} error={anteError}
setError={setAnteError} setError={setAnteError}
disabled={isSubmitting} disabled={isSubmitting}
contractId={undefined}
/> />
</div> </div>

View File

@ -245,6 +245,7 @@ ${TEST_VALUE}
error={anteError} error={anteError}
setError={setAnteError} setError={setAnteError}
disabled={isSubmitting} disabled={isSubmitting}
contractId={undefined}
/> />
</div> </div>