From 985cdd25379aa69773fdfc0010ce343933c3ea74 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 1 Mar 2022 21:31:48 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=A6=20Per-market=20loans!=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- common/bet.ts | 3 + common/new-bet.ts | 21 ++++++- common/payouts.ts | 9 +++ common/sell-bet.ts | 4 +- functions/src/create-answer.ts | 19 +++++- functions/src/place-bet.ts | 25 +++++++- functions/src/resolve-market.ts | 20 +++++- web/components/amount-input.tsx | 56 ++++++++++++++--- web/components/answers/answer-bet-panel.tsx | 63 ++++++++++--------- web/components/answers/answer-item.tsx | 1 + .../answers/create-answer-panel.tsx | 46 ++++++++------ web/components/bet-panel.tsx | 53 ++++++++-------- web/components/bets-list.tsx | 39 +++++++----- web/hooks/use-user-bets.ts | 20 +++++- web/lib/firebase/bets.ts | 15 +++++ web/lib/firebase/utils.ts | 9 ++- web/pages/[username]/[contractSlug].tsx | 2 +- web/pages/create.tsx | 1 + web/pages/make-predictions.tsx | 1 + 19 files changed, 291 insertions(+), 116 deletions(-) diff --git a/common/bet.ts b/common/bet.ts index 7da4b18c..a3e8e714 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,6 +4,7 @@ export type Bet = { contractId: string amount: number // bet size; negative if SELL bet + loanAmount?: number outcome: string shares: number // dynamic parimutuel pool weight; negative if SELL bet @@ -21,3 +22,5 @@ export type Bet = { createdTime: number } + +export const MAX_LOAN_PER_CONTRACT = 20 diff --git a/common/new-bet.ts b/common/new-bet.ts index 29fd421a..0e94c85a 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,4 +1,5 @@ -import { Bet } from './bet' +import * as _ from 'lodash' +import { Bet, MAX_LOAN_PER_CONTRACT } from './bet' import { calculateShares, getProbability, @@ -11,6 +12,7 @@ export const getNewBinaryBetInfo = ( user: User, outcome: 'YES' | 'NO', amount: number, + loanAmount: number, contract: Contract, newBetId: string ) => { @@ -45,6 +47,7 @@ export const getNewBinaryBetInfo = ( userId: user.id, contractId: contract.id, amount, + loanAmount, shares, outcome, probBefore, @@ -52,7 +55,7 @@ export const getNewBinaryBetInfo = ( createdTime: Date.now(), } - const newBalance = user.balance - amount + const newBalance = user.balance - (amount - loanAmount) return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } @@ -61,6 +64,7 @@ export const getNewMultiBetInfo = ( user: User, outcome: string, amount: number, + loanAmount: number, contract: Contract, newBetId: string ) => { @@ -85,6 +89,7 @@ export const getNewMultiBetInfo = ( userId: user.id, contractId: contract.id, amount, + loanAmount, shares, outcome, probBefore, @@ -92,7 +97,17 @@ export const getNewMultiBetInfo = ( createdTime: Date.now(), } - const newBalance = user.balance - amount + const newBalance = user.balance - (amount - loanAmount) 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 +} diff --git a/common/payouts.ts b/common/payouts.ts index 5c29d6a9..446b75d7 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -161,3 +161,12 @@ export const getPayoutsMultiOutcome = ( .map(({ userId, payout }) => ({ userId, payout })) .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 })) +} diff --git a/common/sell-bet.ts b/common/sell-bet.ts index cc824386..1b3b133d 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -11,7 +11,7 @@ export const getSellBetInfo = ( newBetId: string ) => { 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) @@ -57,7 +57,7 @@ export const getSellBetInfo = ( }, } - const newBalance = user.balance + saleAmount + const newBalance = user.balance + saleAmount - (loanAmount ?? 0) return { newBet, diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 5e711e03..f192fc7e 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -3,10 +3,11 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { User } from '../../common/user' -import { getNewMultiBetInfo } from '../../common/new-bet' +import { getLoanAmount, getNewMultiBetInfo } from '../../common/new-bet' import { Answer } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' +import { Bet } from '../../common/bet' export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -55,6 +56,11 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( if (closeTime && Date.now() > closeTime) 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( firestore .collection(`contracts/${contractId}/answers`) @@ -92,8 +98,17 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() + const loanAmount = getLoanAmount(yourBets, amount) + 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.update(contractDoc, { diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index e37dc12c..4adb1779 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -3,7 +3,13 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' 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( async ( @@ -46,6 +52,11 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( if (closeTime && Date.now() > closeTime) 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') { const answerSnap = await transaction.get( contractDoc.collection('answers').doc(outcome) @@ -58,16 +69,26 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() + const loanAmount = getLoanAmount(yourBets, amount) + const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = outcomeType === 'BINARY' ? getNewBinaryBetInfo( user, outcome as 'YES' | 'NO', amount, + loanAmount, + contract, + newBetDoc.id + ) + : getNewMultiBetInfo( + user, + outcome, + amount, + loanAmount, contract, newBetDoc.id ) - : getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id) transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 5da5b272..9e3746a2 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -7,7 +7,11 @@ import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, payUser } from './utils' import { sendMarketResolutionEmail } from './emails' -import { getPayouts, getPayoutsMultiOutcome } from '../../common/payouts' +import { + getLoanPayouts, + getPayouts, + getPayoutsMultiOutcome, +} from '../../common/payouts' import { removeUndefinedProps } from '../../common/util/object' export const resolveMarket = functions @@ -99,13 +103,23 @@ export const resolveMarket = functions ? getPayoutsMultiOutcome(resolutions, contract, openBets) : getPayouts(outcome, contract, openBets, resolutionProbability) + const loanPayouts = getLoanPayouts(openBets) + console.log('payouts:', payouts) - const groups = _.groupBy(payouts, (payout) => payout.userId) + const groups = _.groupBy( + [...payouts, ...loanPayouts], + (payout) => payout.userId + ) const userPayouts = _.mapValues(groups, (group) => _.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( ([userId, payout]) => payUser(userId, payout) ) @@ -116,7 +130,7 @@ export const resolveMarket = functions await sendResolutionEmails( openBets, - userPayouts, + userPayoutsWithoutLoans, creator, contract, outcome, diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index dcc29465..e36962a2 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -1,15 +1,20 @@ import clsx from 'clsx' +import _ from 'lodash' import { useUser } from '../hooks/use-user' import { formatMoney } from '../../common/util/format' -import { AddFundsButton } from './add-funds-button' import { Col } from './layout/col' 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: { amount: number | undefined onChange: (newAmount: number | undefined) => void error: string | undefined setError: (error: string | undefined) => void + contractId: string | undefined minimumAmount?: number disabled?: boolean className?: string @@ -22,6 +27,7 @@ export function AmountInput(props: { onChange, error, setError, + contractId, disabled, className, inputClassName, @@ -31,10 +37,24 @@ export function AmountInput(props: { 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) => { + if (str.includes('-')) { + onChange(undefined) + return + } const amount = parseInt(str.replace(/[^\d]/, '')) if (str && isNaN(amount)) return + if (amount >= 10 ** 9) return 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 ( @@ -68,19 +89,34 @@ export function AmountInput(props: { onChange={(e) => onAmountChange(e.target.value)} /> + + + {error && ( -
+
{error}
)} {user && ( - -
- Remaining balance -
- -
{formatMoney(Math.floor(remainingBalance))}
- {user.balance !== 1000 && } + + {contractId && ( + + + Amount loaned{' '} + + + {formatMoney(loanAmount)}{' '} + + )} + + Remaining balance{' '} + + {formatMoney(Math.floor(remainingBalance))} + )} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 33a0593f..26939b35 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -30,8 +30,9 @@ export function AnswerBetPanel(props: { answer: Answer contract: Contract closePanel: () => void + className?: string }) { - const { answer, contract, closePanel } = props + const { answer, contract, closePanel, className } = props const { id: answerId } = answer const user = useUser() @@ -97,7 +98,7 @@ export function AnswerBetPanel(props: { const currentReturnPercent = (currentReturn * 100).toFixed() + '%' return ( - +
Buy this answer
@@ -114,40 +115,44 @@ export function AnswerBetPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + contractId={contract.id} /> + + +
Probability
+ +
{formatPercent(initialProb)}
+
+
{formatPercent(resultProb)}
+
+
- - -
Implied probability
- -
{formatPercent(initialProb)}
-
-
{formatPercent(resultProb)}
-
- - - - - Payout if chosen - - -
- {formatMoney(currentPayout)} -   (+{currentReturnPercent}) -
+ + +
Payout if chosen
+ +
+ + + {formatMoney(currentPayout)} + + (+{currentReturnPercent}) + +
+ {user ? (