diff --git a/common/antes.ts b/common/antes.ts index 17c58e96..f123e882 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -3,7 +3,7 @@ import { getProbability } from './calculate' import { Contract } from './contract' import { User } from './user' -export const PHANTOM_ANTE = 100 +export const PHANTOM_ANTE = 0.001 export const MINIMUM_ANTE = 10 export const calcStartPool = (initialProbInt: number, ante = 0) => { diff --git a/common/calculate.ts b/common/calculate.ts index eeec5251..52a51b83 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -116,7 +116,9 @@ export function calculateShareValue(contract: Contract, bet: Bet) { } export function calculateSaleAmount(contract: Contract, bet: Bet) { - return (1 - FEES) * calculateShareValue(contract, bet) + const { amount } = bet + const winnings = calculateShareValue(contract, bet) + return deductFees(amount, winnings) } export function calculatePayout( @@ -145,19 +147,16 @@ export function calculateStandardPayout( const { amount, outcome: betOutcome, shares } = bet if (betOutcome !== outcome) return 0 - const { totalShares, totalBets, phantomShares } = contract + const { totalShares, phantomShares } = contract if (!totalShares[outcome]) return 0 - const truePool = _.sum(Object.values(totalShares)) + const pool = _.sum(Object.values(totalShares)) - if (totalBets[outcome] >= truePool) - return (amount / totalBets[outcome]) * truePool + const total = totalShares[outcome] - phantomShares[outcome] - const total = - totalShares[outcome] - phantomShares[outcome] - totalBets[outcome] - const winningsPool = truePool - totalBets[outcome] - - return amount + (1 - FEES) * ((shares - amount) / total) * winningsPool + const winnings = (shares / total) * pool + // profit can be negative if using phantom shares + return amount + (1 - FEES) * Math.max(0, winnings - amount) } export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { @@ -191,35 +190,18 @@ function calculateMktPayout(contract: Contract, bet: Bet) { ? contract.resolutionProbability : getProbability(contract.totalShares) - const weightedTotal = - p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO - - const truePool = contract.pool.YES + contract.pool.NO - - const betP = bet.outcome === 'YES' ? p : 1 - p - - if (weightedTotal >= truePool) { - return ((betP * bet.amount) / weightedTotal) * truePool - } - - const winningsPool = truePool - weightedTotal + const pool = contract.pool.YES + contract.pool.NO const weightedShareTotal = - p * - (contract.totalShares.YES - - contract.phantomShares.YES - - contract.totalBets.YES) + - (1 - p) * - (contract.totalShares.NO - - contract.phantomShares.NO - - contract.totalBets.NO) + p * (contract.totalShares.YES - contract.phantomShares.YES) + + (1 - p) * (contract.totalShares.NO - contract.phantomShares.NO) - return ( - betP * bet.amount + - (1 - FEES) * - ((betP * (bet.shares - bet.amount)) / weightedShareTotal) * - winningsPool - ) + const { outcome, amount, shares } = bet + + const betP = outcome === 'YES' ? p : 1 - p + const winnings = ((betP * shares) / weightedShareTotal) * pool + + return deductFees(amount, winnings) } export function resolvedPayout(contract: Contract, bet: Bet) { @@ -236,3 +218,9 @@ export function currentValue(contract: Contract, bet: Bet) { return prob * yesPayout + (1 - prob) * noPayout } + +export const deductFees = (betAmount: number, winnings: number) => { + return winnings > betAmount + ? betAmount + (1 - FEES) * (winnings - betAmount) + : winnings +} diff --git a/common/payouts.ts b/common/payouts.ts index 77630502..a372f6bc 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,5 +1,7 @@ +import * as _ from 'lodash' + import { Bet } from './bet' -import { getProbability } from './calculate' +import { deductFees, getProbability } from './calculate' import { Contract, outcome } from './contract' import { CREATOR_FEE, FEES } from './fees' @@ -8,7 +10,7 @@ export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { const poolTotal = pool.YES + pool.NO console.log('resolved N/A, pool M$', poolTotal) - const betSum = sumBy(bets, (b) => b.amount) + const betSum = _.sumBy(bets, (b) => b.amount) return bets.map((bet) => ({ userId: bet.userId, @@ -21,42 +23,38 @@ export const getStandardPayouts = ( contract: Contract, bets: Bet[] ) => { - const [yesBets, noBets] = partition(bets, (bet) => bet.outcome === 'YES') + const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') const winningBets = outcome === 'YES' ? yesBets : noBets - const betSum = sumBy(winningBets, (b) => b.amount) + const pool = contract.pool.YES + contract.pool.NO + const totalShares = _.sumBy(winningBets, (b) => b.shares) - const poolTotal = contract.pool.YES + contract.pool.NO + const payouts = winningBets.map(({ userId, amount, shares }) => { + const winnings = (shares / totalShares) * pool + const profit = winnings - amount - if (betSum >= poolTotal) return getCancelPayouts(contract, winningBets) + // profit can be negative if using phantom shares + const payout = amount + (1 - FEES) * Math.max(0, profit) + return { userId, profit, payout } + }) - const shareDifferenceSum = sumBy(winningBets, (b) => b.shares - b.amount) - - const winningsPool = poolTotal - betSum - - const winnerPayouts = winningBets.map((bet) => ({ - userId: bet.userId, - payout: - bet.amount + - (1 - FEES) * - ((bet.shares - bet.amount) / shareDifferenceSum) * - winningsPool, - })) - - const creatorPayout = CREATOR_FEE * winningsPool + const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit)) + const creatorPayout = CREATOR_FEE * profits console.log( 'resolved', outcome, - 'pool: M$', - poolTotal, - 'creator fee: M$', + 'pool', + pool, + 'profits', + profits, + 'creator fee', creatorPayout ) - return winnerPayouts.concat([ - { userId: contract.creatorId, payout: creatorPayout }, - ]) // add creator fee + return payouts + .map(({ userId, payout }) => ({ userId, payout })) + .concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee } export const getMktPayouts = ( @@ -69,56 +67,37 @@ export const getMktPayouts = ( ? getProbability(contract.totalShares) : resolutionProbability - const poolTotal = contract.pool.YES + contract.pool.NO - console.log('Resolved MKT at p=', p, 'pool: $M', poolTotal) + const weightedShareTotal = _.sumBy(bets, (b) => + b.outcome === 'YES' ? p * b.shares : (1 - p) * b.shares + ) - const [yesBets, noBets] = partition(bets, (bet) => bet.outcome === 'YES') + const pool = contract.pool.YES + contract.pool.NO - const weightedBetTotal = - p * sumBy(yesBets, (b) => b.amount) + - (1 - p) * sumBy(noBets, (b) => b.amount) + const payouts = bets.map(({ userId, outcome, amount, shares }) => { + const betP = outcome === 'YES' ? p : 1 - p + const winnings = ((betP * shares) / weightedShareTotal) * pool + const profit = winnings - amount + const payout = deductFees(amount, winnings) + return { userId, profit, payout } + }) - if (weightedBetTotal >= poolTotal) { - return bets.map((bet) => ({ - userId: bet.userId, - payout: - (((bet.outcome === 'YES' ? p : 1 - p) * bet.amount) / - weightedBetTotal) * - poolTotal, - })) - } + const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit)) + const creatorPayout = CREATOR_FEE * profits - const winningsPool = poolTotal - weightedBetTotal + console.log( + 'resolved MKT', + p, + 'pool', + pool, + 'profits', + profits, + 'creator fee', + creatorPayout + ) - const weightedShareTotal = - p * sumBy(yesBets, (b) => b.shares - b.amount) + - (1 - p) * sumBy(noBets, (b) => b.shares - b.amount) - - const yesPayouts = yesBets.map((bet) => ({ - userId: bet.userId, - payout: - p * bet.amount + - (1 - FEES) * - ((p * (bet.shares - bet.amount)) / weightedShareTotal) * - winningsPool, - })) - - const noPayouts = noBets.map((bet) => ({ - userId: bet.userId, - payout: - (1 - p) * bet.amount + - (1 - FEES) * - (((1 - p) * (bet.shares - bet.amount)) / weightedShareTotal) * - winningsPool, - })) - - const creatorPayout = CREATOR_FEE * winningsPool - - return [ - ...yesPayouts, - ...noPayouts, - { userId: contract.creatorId, payout: creatorPayout }, - ] + return payouts + .map(({ userId, payout }) => ({ userId, payout })) + .concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee } export const getPayouts = ( @@ -137,20 +116,3 @@ export const getPayouts = ( return getCancelPayouts(contract, bets) } } - -const partition = (array: T[], f: (t: T) => boolean) => { - const yes = [] - const no = [] - - for (let t of array) { - if (f(t)) yes.push(t) - else no.push(t) - } - - return [yes, no] as [T[], T[]] -} - -const sumBy = (array: T[], f: (t: T) => number) => { - const values = array.map(f) - return values.reduce((prev, cur) => prev + cur, 0) -} diff --git a/common/sell-bet.ts b/common/sell-bet.ts index eeeed355..88191742 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -1,5 +1,5 @@ import { Bet } from './bet' -import { calculateShareValue, getProbability } from './calculate' +import { calculateShareValue, deductFees, getProbability } from './calculate' import { Contract } from './contract' import { CREATOR_FEE, FEES } from './fees' import { User } from './user' @@ -36,8 +36,9 @@ export const getSellBetInfo = ( const probBefore = getProbability(contract.totalShares) const probAfter = getProbability(newTotalShares) - const creatorFee = CREATOR_FEE * adjShareValue - const saleAmount = (1 - FEES) * adjShareValue + const profit = adjShareValue - amount + const creatorFee = CREATOR_FEE * Math.max(0, profit) + const saleAmount = deductFees(amount, adjShareValue) console.log( 'SELL M$', diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6816a8d7..5f297595 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -30,7 +30,9 @@ export const resolveMarket = functions if ( probabilityInt !== undefined && - (probabilityInt < 1 || probabilityInt > 99 || !isFinite(probabilityInt)) + (probabilityInt < 0 || + probabilityInt > 100 || + !isFinite(probabilityInt)) ) return { status: 'error', message: 'Invalid probability' } diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 2c973536..e0c8c320 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -23,13 +23,15 @@ import { import { Row } from './layout/row' import { UserLink } from './user-page' import { + calculateCancelPayout, calculatePayout, calculateSaleAmount, + getProbability, resolvedPayout, } from '../../common/calculate' import { sellBet } from '../lib/firebase/api-call' import { ConfirmationButton } from './confirmation-button' -import { OutcomeLabel, YesLabel, NoLabel, MarketLabel } from './outcome-label' +import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' type BetSort = 'newest' | 'profit' @@ -119,16 +121,32 @@ export function BetsList(props: { user: User }) { (c) => contractsCurrentValue[c.id] ) + const totalPortfolio = currentBetsValue + user.balance + + const pnl = totalPortfolio - user.totalDeposits + const totalReturn = + (pnl > 0 ? '+' : '') + ((pnl / user.totalDeposits) * 100).toFixed() + '%' + return ( + +
Total portfolio
+
{formatMoney(totalPortfolio)}
+ + +
Total profits & losses
+
+ {formatMoney(pnl)} ({totalReturn}) +
+
Currently invested
{formatMoney(currentInvestment)}
-
Current value
+
Current market value
{formatMoney(currentBetsValue)}
@@ -231,6 +249,7 @@ export function MyBetsSummary(props: { }) { const { bets, contract, showMKT, className } = props const { resolution } = contract + calculateCancelPayout const excludeSales = bets.filter((b) => !b.isSold && !b.sale) const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount) @@ -284,7 +303,10 @@ export function MyBetsSummary(props: { {showMKT && (
- Payout if + Payout at{' '} + + {formatPercent(getProbability(contract.totalShares))} +
{formatMoney(marketWinnings)} diff --git a/web/components/edit-fold-button.tsx b/web/components/edit-fold-button.tsx index de88f148..bc4805c2 100644 --- a/web/components/edit-fold-button.tsx +++ b/web/components/edit-fold-button.tsx @@ -25,6 +25,7 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) { const [isSubmitting, setIsSubmitting] = useState(false) const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags) + const lowercaseTags = tags.map((tag) => tag.toLowerCase()) const saveDisabled = name === fold.name && @@ -38,6 +39,7 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) { name, about, tags, + lowercaseTags, }) setIsSubmitting(false) diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index ec65667d..6d5acbe1 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -21,10 +21,6 @@ export function CancelLabel() { return N/A } -export function MarketLabel() { - return MKT -} - export function ProbLabel() { return PROB } diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 06d5d573..cae4f53a 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -64,9 +64,9 @@ export function ResolutionPanel(props: { return ( - + <Title className="mt-0" text="Resolve market" /> - <div className="pt-2 pb-1 text-sm text-gray-500">Resolve outcome</div> + <div className="pt-2 pb-1 text-sm text-gray-500">Outcome</div> <YesNoCancelSelector className="mx-auto my-2" @@ -80,20 +80,30 @@ export function ResolutionPanel(props: { <div> {outcome === 'YES' ? ( <> - Winnings will be paid out to YES bettors. You earn{' '} - {CREATOR_FEE * 100}% of trader profits. + Winnings will be paid out to YES bettors. + <br /> + <br /> + You earn {CREATOR_FEE * 100}% of trader profits. </> ) : outcome === 'NO' ? ( <> - Winnings will be paid out to NO bettors. You earn{' '} - {CREATOR_FEE * 100}% of trader profits. + Winnings will be paid out to NO bettors. + <br /> + <br /> + You earn {CREATOR_FEE * 100}% of trader profits. </> ) : outcome === 'CANCEL' ? ( <>The pool will be returned to traders with no fees.</> ) : outcome === 'MKT' ? ( <> - Traders will be paid out at the probability you specify. You earn{' '} - {CREATOR_FEE * 100}% of trader profits. + Traders will be paid out at the probability you specify: + <Spacer h={2} /> + <ProbabilitySelector + probabilityInt={Math.round(prob)} + setProbabilityInt={setProb} + /> + <Spacer h={2} /> + You earn {CREATOR_FEE * 100}% of trader profits. </> ) : ( <>Resolving this market will immediately pay out traders.</> @@ -123,20 +133,7 @@ export function ResolutionPanel(props: { }} onSubmit={resolve} > - {outcome === 'MKT' ? ( - <> - <p className="mb-4"> - What probability would you like to resolve the market to? - </p> - - <ProbabilitySelector - probabilityInt={Math.round(prob)} - setProbabilityInt={setProb} - /> - </> - ) : ( - <p>Are you sure you want to resolve this market?</p> - )} + <p>Are you sure you want to resolve this market?</p> </ConfirmationButton> </Col> ) diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index 1e159aaa..35cbf183 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -48,47 +48,44 @@ export function YesNoCancelSelector(props: { className?: string btnClassName?: string }) { - const { selected, onSelect, className } = props + const { selected, onSelect } = props const btnClassName = clsx('px-6 flex-1', props.btnClassName) return ( - <Col> - <Row className={clsx('w-full space-x-3', className)}> - <Button - color={selected === 'YES' ? 'green' : 'gray'} - onClick={() => onSelect('YES')} - className={btnClassName} - > - YES - </Button> + <Col className="gap-2"> + {/* Should ideally use a radio group instead of buttons */} + <Button + color={selected === 'YES' ? 'green' : 'gray'} + onClick={() => onSelect('YES')} + className={btnClassName} + > + YES + </Button> - <Button - color={selected === 'NO' ? 'red' : 'gray'} - onClick={() => onSelect('NO')} - className={btnClassName} - > - NO - </Button> - </Row> + <Button + color={selected === 'NO' ? 'red' : 'gray'} + onClick={() => onSelect('NO')} + className={btnClassName} + > + NO + </Button> - <Row className={clsx('w-full space-x-3', className)}> - <Button - color={selected === 'MKT' ? 'blue' : 'gray'} - onClick={() => onSelect('MKT')} - className={clsx(btnClassName, 'btn-sm')} - > - PROB - </Button> + <Button + color={selected === 'MKT' ? 'blue' : 'gray'} + onClick={() => onSelect('MKT')} + className={clsx(btnClassName, 'btn-sm')} + > + PROB + </Button> - <Button - color={selected === 'CANCEL' ? 'yellow' : 'gray'} - onClick={() => onSelect('CANCEL')} - className={clsx(btnClassName, 'btn-sm')} - > - N/A - </Button> - </Row> + <Button + color={selected === 'CANCEL' ? 'yellow' : 'gray'} + onClick={() => onSelect('CANCEL')} + className={clsx(btnClassName, 'btn-sm')} + > + N/A + </Button> </Col> ) } diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index 8094ebf6..637e2801 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -1,14 +1,14 @@ import { Page } from '../components/page' -import { Grid } from 'gridjs-react' +import { Grid, _ as r } from 'gridjs-react' import 'gridjs/dist/theme/mermaid.css' import { html } from 'gridjs' import dayjs from 'dayjs' import { usePrivateUsers, useUsers } from '../hooks/use-users' -import { useUser } from '../hooks/use-user' import Custom404 from './404' import { useContracts } from '../hooks/use-contracts' import _ from 'lodash' import { useAdmin } from '../hooks/use-admin' +import { contractPath } from '../lib/firebase/contracts' function avatarHtml(avatarUrl: string) { return `<img @@ -111,6 +111,20 @@ function ContractsTable() { let contracts = useContracts() ?? [] // Sort users by createdTime descending, by default contracts.sort((a, b) => b.createdTime - a.createdTime) + // Render a clickable question. See https://gridjs.io/docs/examples/react-cells for docs + contracts.map((contract) => { + // @ts-ignore + contract.questionLink = r( + <div className="w-60"> + <a + className="hover:underline hover:decoration-indigo-400 hover:decoration-2" + href={contractPath(contract)} + > + {contract.question} + </a> + </div> + ) + }) return ( <Grid @@ -126,9 +140,8 @@ function ContractsTable() { href="/${cell}">@${cell}</a>`), }, { - id: 'question', + id: 'questionLink', name: 'Question', - formatter: (cell) => html(`<div class="w-60">${cell}</div>`), }, { id: 'volume24Hours',