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..ab7f00af 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -138,8 +138,7 @@ export const getPayoutsMultiOutcome = ( const prob = resolutions[outcome] / probTotal const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal const profit = winnings - amount - - const payout = amount + (1 - FEES) * Math.max(0, profit) + const payout = deductFees(amount, winnings) return { userId, profit, payout } }) @@ -161,3 +160,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/scoring.ts b/common/scoring.ts index 6940a019..f7ed1532 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -45,8 +45,9 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) { const investments = bets .filter((bet) => !bet.sale) .map((bet) => { - const { userId, amount } = bet - return { userId, payout: -amount } + const { userId, amount, loanAmount } = bet + const payout = -amount - (loanAmount ?? 0) + return { userId, payout } }) const netPayouts = [...resolvePayouts, ...salePayouts, ...investments] 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/emails.ts b/functions/src/emails.ts index 00696186..0ded7b7d 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -223,6 +223,10 @@ export const sendNewAnswerEmail = async ( ) => { // Send to just the creator for now. const { creatorId: userId } = contract + + // Don't send the creator's own answers. + if (answer.userId === userId) return + const privateUser = await getPrivateUser(userId) if ( !privateUser || diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index e37dc12c..f473a2e2 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -3,7 +3,12 @@ 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' export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -33,9 +38,6 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( return { status: 'error', message: 'User not found' } const user = userSnap.data() as User - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } - const contractDoc = firestore.doc(`contracts/${contractId}`) const contractSnap = await transaction.get(contractDoc) if (!contractSnap.exists) @@ -46,6 +48,15 @@ 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) + + const loanAmount = getLoanAmount(yourBets, amount) + if (user.balance < amount - loanAmount) + return { status: 'error', message: 'Insufficient balance' } + if (outcomeType === 'FREE_RESPONSE') { const answerSnap = await transaction.get( contractDoc.collection('answers').doc(outcome) @@ -64,10 +75,18 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( 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/functions/src/update-user-metrics.ts b/functions/src/update-user-metrics.ts index f59d4a34..d1d13727 100644 --- a/functions/src/update-user-metrics.ts +++ b/functions/src/update-user-metrics.ts @@ -52,7 +52,8 @@ const computeInvestmentValue = async ( if (!contract || contract.isResolved) return 0 if (bet.sale || bet.isSold) return 0 - return calculatePayout(contract, bet, 'MKT') + const payout = calculatePayout(contract, bet, 'MKT') + return payout - (bet.loanAmount ?? 0) }) } diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 0e87538a..f34db1c8 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -68,8 +68,7 @@ const updateUserBalance = ( } export const payUser = (userId: string, payout: number, isDeposit = false) => { - if (!isFinite(payout) || payout <= 0) - throw new Error('Payout is not positive: ' + payout) + if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) return updateUserBalance(userId, payout, isDeposit) } diff --git a/web/pages/activity.tsx b/web/components/activity-feed.tsx similarity index 74% rename from web/pages/activity.tsx rename to web/components/activity-feed.tsx index bab58328..bfd4cc1c 100644 --- a/web/pages/activity.tsx +++ b/web/components/activity-feed.tsx @@ -1,9 +1,12 @@ import _ from 'lodash' -import { ContractFeed, ContractSummaryFeed } from '../components/contract-feed' -import { Page } from '../components/page' +import { + ContractActivityFeed, + ContractFeed, + ContractSummaryFeed, +} from './contract-feed' import { Contract } from '../lib/firebase/contracts' import { Comment } from '../lib/firebase/comments' -import { Col } from '../components/layout/col' +import { Col } from './layout/col' import { Bet } from '../../common/bet' const MAX_ACTIVE_CONTRACTS = 75 @@ -72,30 +75,44 @@ export function findActiveContracts( export function ActivityFeed(props: { contracts: Contract[] - contractBets: Bet[][] - contractComments: Comment[][] + recentBets: Bet[] + recentComments: Comment[] + loadBetAndCommentHistory?: boolean }) { - const { contracts, contractBets, contractComments } = props + const { contracts, recentBets, recentComments, loadBetAndCommentHistory } = + props - return contracts.length > 0 ? ( + const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) + const groupedComments = _.groupBy( + recentComments, + (comment) => comment.contractId + ) + + return ( - + - {contracts.map((contract, i) => ( + {contracts.map((contract) => (
- + {loadBetAndCommentHistory ? ( + + ) : ( + + )}
))} - ) : ( - <> ) } @@ -116,11 +133,3 @@ export function SummaryActivityFeed(props: { contracts: Contract[] }) { ) } - -export default function ActivityPage() { - return ( - - - - ) -} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index dcc29465..8f20c0ab 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 + contractIdForLoan: string | undefined minimumAmount?: number disabled?: boolean className?: string @@ -22,6 +27,7 @@ export function AmountInput(props: { onChange, error, setError, + contractIdForLoan, disabled, className, inputClassName, @@ -31,14 +37,32 @@ export function AmountInput(props: { const user = useUser() + const userBets = useUserContractBets(user?.id, contractIdForLoan) ?? [] + const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) + const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0) + + const loanAmount = contractIdForLoan + ? Math.min(amount ?? 0, MAX_LOAN_PER_CONTRACT - prevLoanAmount) + : 0 + 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) - if (user && user.balance < amount) { + const loanAmount = contractIdForLoan + ? Math.min(amount, MAX_LOAN_PER_CONTRACT - prevLoanAmount) + : 0 + const amountNetLoan = amount - loanAmount + + if (user && user.balance < amountNetLoan) { setError('Insufficient balance') } else if (minimumAmount && amount < minimumAmount) { setError('Minimum amount: ' + formatMoney(minimumAmount)) @@ -47,7 +71,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 +93,34 @@ export function AmountInput(props: { onChange={(e) => onAmountChange(e.target.value)} /> + + + {error && ( -
+
{error}
)} {user && ( - -
- Remaining balance -
- -
{formatMoney(Math.floor(remainingBalance))}
- {user.balance !== 1000 && } + + {contractIdForLoan && ( + + + Amount loaned{' '} + + + {formatMoney(loanAmount)}{' '} + + )} + + Remaining balance{' '} + + {formatMoney(Math.floor(remainingBalance))} + )} diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx new file mode 100644 index 00000000..3a315d38 --- /dev/null +++ b/web/components/analytics/charts.tsx @@ -0,0 +1,49 @@ +import { ResponsiveLine } from '@nivo/line' +import dayjs from 'dayjs' +import _ from 'lodash' +import { useWindowSize } from '../../hooks/use-window-size' + +export function DailyCountChart(props: { + startDate: number + dailyCounts: number[] + small?: boolean +}) { + const { dailyCounts, startDate, small } = props + const { width } = useWindowSize() + + const dates = dailyCounts.map((_, i) => + dayjs(startDate).add(i, 'day').toDate() + ) + + const points = _.zip(dates, dailyCounts).map(([date, betCount]) => ({ + x: date, + y: betCount, + })) + const data = [{ id: 'Count', data: points, color: '#11b981' }] + + return ( +
= 800) ? 400 : 250 }} + > + dayjs(date).format('MMM DD'), + }} + colors={{ datum: 'color' }} + pointSize={width && width >= 800 ? 10 : 0} + pointBorderWidth={1} + pointBorderColor="#fff" + enableSlices="x" + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 40 }} + /> +
+ ) +} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 33a0593f..82b0967b 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() @@ -48,11 +49,6 @@ export function AnswerBetPanel(props: { async function submitBet() { if (!user || !betAmount) return - if (user.balance < betAmount) { - setError('Insufficient balance') - return - } - setError(undefined) setIsSubmitting(true) @@ -97,12 +93,12 @@ export function AnswerBetPanel(props: { const currentReturnPercent = (currentReturn * 100).toFixed() + '%' return ( - - + +
Buy this answer
Amount
@@ -114,40 +110,44 @@ export function AnswerBetPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + contractIdForLoan={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 ? (
+
No answers yet...
) : ( -
+
None of the above:{' '} {formatPercent(getOutcomeProbability(contract.totalShares, '0'))}
diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 934310e4..8e5b88a8 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -36,6 +36,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) { const submitAnswer = async () => { if (canSubmit) { setIsSubmitting(true) + const result = await createAnswer({ contractId: contract.id, text, @@ -48,7 +49,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) { setText('') setBetAmount(10) setAmountError(undefined) - } + } else setAmountError(result.message) } } @@ -72,7 +73,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) { const currentReturnPercent = (currentReturn * 100).toFixed() + '%' return ( - +
Add your answer