diff --git a/common/bet.ts b/common/bet.ts index fbfc0387..8afebcd8 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -14,19 +14,21 @@ export type Bet = { probBefore: number probAfter: number - sale?: { - amount: number // amount user makes from sale - betId: string // id of bet being sold - // TODO: add sale time? - } - fees: Fees - isSold?: boolean // true if this BUY bet has been sold isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean challengeSlug?: string + + // Props for bets in DPM contract below. + // A bet is either a BUY or a SELL that sells all of a previous buy. + isSold?: boolean // true if this BUY bet has been sold + // This field marks a SELL bet. + sale?: { + amount: number // amount user makes from sale + betId: string // id of BUY bet being sold + } } & Partial export type NumericBet = Bet & { diff --git a/common/calculate.ts b/common/calculate.ts index d25fd313..758fc3cd 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,4 +1,4 @@ -import { maxBy } from 'lodash' +import { maxBy, sortBy, sum, sumBy } from 'lodash' import { Bet, LimitBet } from './bet' import { calculateCpmmSale, @@ -133,10 +133,46 @@ export function resolvedPayout(contract: Contract, bet: Bet) { : calculateDpmPayout(contract, bet, outcome) } +function getCpmmInvested(yourBets: Bet[]) { + const totalShares: { [outcome: string]: number } = {} + const totalSpent: { [outcome: string]: number } = {} + + const sortedBets = sortBy(yourBets, 'createdTime') + for (const bet of sortedBets) { + const { outcome, shares, amount } = bet + if (amount > 0) { + totalShares[outcome] = (totalShares[outcome] ?? 0) + shares + totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount + } else if (amount < 0) { + const averagePrice = totalSpent[outcome] / totalShares[outcome] + totalShares[outcome] = totalShares[outcome] + shares + totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares + } + } + + return sum(Object.values(totalSpent)) +} + +function getDpmInvested(yourBets: Bet[]) { + const sortedBets = sortBy(yourBets, 'createdTime') + + return sumBy(sortedBets, (bet) => { + const { amount, sale } = bet + + if (sale) { + const originalBet = sortedBets.find((b) => b.id === sale.betId) + if (originalBet) return -originalBet.amount + return 0 + } + + return amount + }) +} + export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const { resolution } = contract + const isCpmm = contract.mechanism === 'cpmm-1' - let currentInvested = 0 let totalInvested = 0 let payout = 0 let loan = 0 @@ -162,7 +198,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { saleValue -= amount } - currentInvested += amount loan += loanAmount ?? 0 payout += resolution ? calculatePayout(contract, bet, resolution) @@ -174,12 +209,13 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 + const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const hasShares = Object.values(totalShares).some( (shares) => !floatingEqual(shares, 0) ) return { - invested: Math.max(0, currentInvested), + invested, payout, netPayout, profit, diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 7277f40b..6f8ea2e9 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' +import { difference, mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -22,6 +22,8 @@ import { isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' +import { getContractBetMetrics } from '../../common/calculate' +import { floatingEqual } from '../../common/util/math' const bodySchema = z.object({ contractId: z.string(), @@ -162,7 +164,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) await sendResolutionEmails( - openBets, + bets, userPayoutsWithoutLoans, creator, creatorPayout, @@ -188,7 +190,7 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { } const sendResolutionEmails = async ( - openBets: Bet[], + bets: Bet[], userPayouts: { [userId: string]: number }, creator: User, creatorPayout: number, @@ -197,14 +199,15 @@ const sendResolutionEmails = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const nonWinners = difference( - uniq(openBets.map(({ userId }) => userId)), - Object.keys(userPayouts) - ) const investedByUser = mapValues( - groupBy(openBets, (bet) => bet.userId), - (bets) => sumBy(bets, (bet) => bet.amount) + groupBy(bets, (bet) => bet.userId), + (bets) => getContractBetMetrics(contract, bets).invested ) + const investedUsers = Object.keys(investedByUser).filter( + (userId) => !floatingEqual(investedByUser[userId], 0) + ) + + const nonWinners = difference(investedUsers, Object.keys(userPayouts)) const emailPayouts = [ ...Object.entries(userPayouts), ...nonWinners.map((userId) => [userId, 0] as const), diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 19b6066e..6ca06cbb 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -1,6 +1,6 @@ import Router from 'next/router' import clsx from 'clsx' -import { MouseEvent } from 'react' +import { MouseEvent, useState } from 'react' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' export function Avatar(props: { @@ -10,7 +10,8 @@ export function Avatar(props: { size?: number | 'xs' | 'sm' className?: string }) { - const { username, avatarUrl, noLink, size, className } = props + const { username, noLink, size, className } = props + const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const onClick = @@ -35,6 +36,11 @@ export function Avatar(props: { src={avatarUrl} onClick={onClick} alt={username} + onError={() => { + // If the image doesn't load, clear the avatarUrl to show the default + // Mostly for localhost, when getting a 403 from googleusercontent + setAvatarUrl('') + }} /> ) : ( - {!isCpmm && ( + +
Invested
{formatMoney(invested)}
- )} - {resolution ? ( -
Payout
+
Profit
- {formatMoney(payout)} + {formatMoney(profit)}
- ) : isBinary ? ( - <> - -
- Payout if -
-
{formatMoney(yesWinnings)}
- - -
- Payout if -
-
{formatMoney(noWinnings)}
- - - ) : isPseudoNumeric ? ( - <> - -
- Payout if {'>='} {formatLargeNumber(contract.max)} -
-
{formatMoney(yesWinnings)}
- - -
- Payout if {'<='} {formatLargeNumber(contract.min)} -
-
{formatMoney(noWinnings)}
- - - ) : ( - -
- Current value -
-
{formatMoney(payout)}
- - )} - -
Profit
-
- {formatMoney(profit)} - {isYourBets && - isCpmm && - (isBinary || isPseudoNumeric) && - !isClosed && - !resolution && - hasShares && - sharesOutcome && - user && ( - <> - - {showSellModal && ( - - )} - + {canSell && ( + <> + + {showSellModal && ( + )} -
- -
+ + )} + + + {resolution ? ( + +
Payout
+
+ {formatMoney(payout)}{' '} + +
+ + ) : isBinary ? ( + <> + +
+ Payout if +
+
+ {formatMoney(yesWinnings)} +
+ + +
+ Payout if +
+
{formatMoney(noWinnings)}
+ + + ) : isPseudoNumeric ? ( + <> + +
+ Payout if {'>='} {formatLargeNumber(contract.max)} +
+
+ {formatMoney(yesWinnings)} +
+ + +
+ Payout if {'<='} {formatLargeNumber(contract.min)} +
+
{formatMoney(noWinnings)}
+ + + ) : ( + +
+ Current value +
+
{formatMoney(payout)}
+ + )} +
+ ) } diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 11d65a13..41360eb7 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -83,7 +83,7 @@ export function ContractSearch(props: { highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void hideOrderSelector?: boolean - overrideGridClassName?: string + gridClassName?: string cardHideOptions?: { hideGroupLink?: boolean hideQuickBet?: boolean @@ -98,7 +98,7 @@ export function ContractSearch(props: { defaultFilter, additionalFilter, onContractClick, - overrideGridClassName, + gridClassName, hideOrderSelector, cardHideOptions, highlightOptions, @@ -181,7 +181,7 @@ export function ContractSearch(props: { loadMore={performQuery} showTime={showTime} onContractClick={onContractClick} - overrideGridClassName={overrideGridClassName} + gridClassName={gridClassName} highlightOptions={highlightOptions} cardHideOptions={cardHideOptions} /> diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 05c66d56..915facd9 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -20,7 +20,7 @@ export function ContractsGrid(props: { loadMore?: () => void showTime?: ShowTime onContractClick?: (contract: Contract) => void - overrideGridClassName?: string + gridClassName?: string cardHideOptions?: { hideQuickBet?: boolean hideGroupLink?: boolean @@ -32,7 +32,7 @@ export function ContractsGrid(props: { showTime, loadMore, onContractClick, - overrideGridClassName, + gridClassName, cardHideOptions, highlightOptions, } = props @@ -66,9 +66,8 @@ export function ContractsGrid(props: { diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index 85b7a978..02925ff3 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -65,9 +65,7 @@ export function MarketModal(props: { c.id), diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 3a751c18..b31b8d04 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -266,12 +266,16 @@ export function listenForHotContracts( }) } -export async function getHotContracts() { - const data = await getValues(hotContractsQuery) - return sortBy( - chooseRandomSubset(data, 10), - (contract) => -1 * contract.volume24Hours - ) +const trendingContractsQuery = query( + contracts, + where('isResolved', '==', false), + where('visibility', '==', 'public'), + orderBy('popularityScore', 'desc'), + limit(10) +) + +export async function getTrendingContracts() { + return await getValues(trendingContractsQuery) } export async function getContractsBySlugs(slugs: string[]) { diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index c5255974..c66d5aa5 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -607,9 +607,7 @@ function AddContractButton(props: { group: Group; user: User }) { user={user} hideOrderSelector={true} onContractClick={addContractToCurrentGroup} - overrideGridClassName={ - 'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1' - } + gridClassName="gap-3 space-y-3" cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} additionalFilter={{ excludeContractIds: group.contractIds }} highlightOptions={{ diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 473189aa..01c24fcf 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,4 +1,4 @@ -import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' +import { Contract, getTrendingContracts } from 'web/lib/firebase/contracts' import { Page } from 'web/components/page' import { LandingPagePanel } from 'web/components/landing-page-panel' import { Col } from 'web/components/layout/col' @@ -8,19 +8,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { SEO } from 'web/components/SEO' export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => { - // These hardcoded markets will be shown in the frontpage for signed-out users: - const hotContracts = await getContractsBySlugs([ - 'will-max-go-to-prom-with-a-girl', - 'will-ethereum-switch-to-proof-of-st', - 'will-russia-control-the-majority-of', - 'will-elon-musk-buy-twitter-this-yea', - 'will-trump-be-charged-by-the-grand', - 'will-spacex-launch-a-starship-into', - 'who-will-win-the-nba-finals-champio', - 'who-will-be-time-magazine-person-of', - 'will-congress-hold-any-hearings-abo-e21f987033b3', - 'will-at-least-10-world-cities-have', - ]) + const hotContracts = await getTrendingContracts() return { props: { hotContracts } } })