Compare commits

...

14 Commits
main ... loans

Author SHA1 Message Date
James Grugett
35410e25d2 Fix to actually show investment instead of current value. And subtract bet amount from current value. 2022-03-01 21:07:22 -08:00
James Grugett
5d5a1860b5 Add comment for includeMetadataChanges. 2022-03-01 19:14:43 -08:00
James Grugett
45d322cfd3 Fix loan calc on front end 2022-03-01 19:07:32 -08:00
James Grugett
5bf4654bf4 Adjust bet panel width 2022-03-01 19:07:32 -08:00
Austin Chen
2692a98151 Reword loan copy 2022-03-01 18:52:34 -08:00
James Grugett
bfee2ea954 Fix bug where listen query was not updating data. 2022-03-01 18:45:10 -08:00
Austin Chen
45fe430a5a Clean up Sell popup UI 2022-03-01 18:29:13 -08:00
James Grugett
4da8000363 Fix layout of create answer bet info 2022-03-01 18:17:16 -08:00
James Grugett
eb7bf72f0c Floor remaining balance 2022-03-01 18:12:55 -08:00
James Grugett
1d73f6e863 Handle bets table for loans. Sell dialog explains how you will repay your loan. 2022-03-01 17:12:29 -08:00
James Grugett
1da30812ad Exclude sold bets from current loan amount 2022-03-01 16:59:46 -08:00
James Grugett
d5ae4a2377 Resolve emails include full payout not subtracting loan 2022-03-01 16:34:08 -08:00
James Grugett
42533c296a Loan frontend: show your loan amount in bet panel, answer bet panel 2022-03-01 16:26:37 -08:00
James Grugett
659f848bec Loan backend: Add loanAmount field to Bet, manage loans up to max loan amount per market -- buy, sell, and resolve. 2022-03-01 16:23:00 -08:00
19 changed files with 294 additions and 118 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

@ -82,7 +82,8 @@ export function BetsList(props: { user: User }) {
if (bet.isSold || bet.sale) return 0 if (bet.isSold || bet.sale) return 0
const contract = contracts.find((c) => c.id === contractId) const contract = contracts.find((c) => c.id === contractId)
return contract ? calculatePayout(contract, bet, 'MKT') : 0 const payout = contract ? calculatePayout(contract, bet, 'MKT') : 0
return payout - (bet.loanAmount ?? 0)
}) })
} }
) )
@ -137,7 +138,7 @@ export function BetsList(props: { user: User }) {
<Col> <Col>
<div className="text-sm text-gray-500">Invested</div> <div className="text-sm text-gray-500">Invested</div>
<div className="text-lg"> <div className="text-lg">
{formatMoney(currentBetsValue)}{' '} {formatMoney(currentInvestment)}{' '}
<ProfitBadge profitPercent={investedProfit} /> <ProfitBadge profitPercent={investedProfit} />
</div> </div>
</Col> </Col>
@ -457,6 +458,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 +466,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 +493,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 +515,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 +539,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 +547,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>