From b9931e65dad9fe8d0d5de921e785a858a8a286b8 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 1 Jul 2022 16:37:30 -0600 Subject: [PATCH 01/10] Allow adding anyone's contract to a group --- firestore.rules | 11 ++- web/components/contract-search.tsx | 49 ++++++++--- web/components/contract/contract-details.tsx | 36 ++++++-- web/components/groups/edit-group-button.tsx | 3 +- web/components/groups/groups-button.tsx | 4 +- web/components/layout/modal.tsx | 11 ++- web/components/layout/tabs.tsx | 14 +-- web/components/nav/sidebar.tsx | 2 +- web/components/user-page.tsx | 2 +- web/hooks/use-group.ts | 4 +- web/lib/firebase/groups.ts | 18 +++- web/pages/create.tsx | 6 +- web/pages/group/[...slugs]/index.tsx | 93 +++++++------------- web/pages/links.tsx | 2 +- web/pages/notifications.tsx | 2 +- 15 files changed, 150 insertions(+), 107 deletions(-) diff --git a/firestore.rules b/firestore.rules index 50df415a..4645343d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,11 +21,16 @@ service cloud.firestore { allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - // only one referral allowed per user allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - && !("referredByUserId" in resource.data); + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && (resource.data.id != request.resource.data.referredByUserId) + // user can't refer someone who referred them quid pro quo + && get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id; + } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 9a4da597..2c7f5b62 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -9,7 +9,7 @@ import { useSortBy, } from 'react-instantsearch-hooks-web' -import { Contract } from '../../common/contract' +import { Contract } from 'common/contract' import { Sort, useInitialQueryAndSort, @@ -58,15 +58,24 @@ export function ContractSearch(props: { additionalFilter?: { creatorId?: string tag?: string + excludeContractIds?: string[] } showCategorySelector: boolean onContractClick?: (contract: Contract) => void + showPlaceHolder?: boolean + hideOrderSelector?: boolean + overrideGridClassName?: string + hideQuickBet?: boolean }) { const { querySortOptions, additionalFilter, showCategorySelector, onContractClick, + overrideGridClassName, + hideOrderSelector, + showPlaceHolder, + hideQuickBet, } = props const user = useUser() @@ -136,6 +145,7 @@ export function ContractSearch(props: { Resolved - + {!hideOrderSelector && ( + + )} )} @@ -199,8 +214,17 @@ export function ContractSearchInner(props: { shouldLoadFromStorage?: boolean } onContractClick?: (contract: Contract) => void + overrideGridClassName?: string + hideQuickBet?: boolean + excludeContractIds?: string[] }) { - const { querySortOptions, onContractClick } = props + const { + querySortOptions, + onContractClick, + overrideGridClassName, + hideQuickBet, + excludeContractIds, + } = props const { initialQuery } = useInitialQueryAndSort(querySortOptions) const { query, setQuery, setSort } = useUpdateQueryAndSort({ @@ -239,7 +263,7 @@ export function ContractSearchInner(props: { }, []) const { showMore, hits, isLastPage } = useInfiniteHits() - const contracts = hits as any as Contract[] + let contracts = hits as any as Contract[] if (isInitialLoad && contracts.length === 0) return <> @@ -249,6 +273,9 @@ export function ContractSearchInner(props: { ? 'resolve-date' : undefined + if (excludeContractIds) + contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) + return ( ) } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3512efa2..f908918e 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -130,9 +130,32 @@ export function ContractDetails(props: { const { contract, bets, isCreator, disabled } = props const { closeTime, creatorName, creatorUsername, creatorId } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - // Find a group that this contract id is in - const groups = useGroupsWithContract(contract.id) + + const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => { + return g2.createdTime - g1.createdTime + }) const user = useUser() + + const groupsUserIsMemberOf = groups + ? groups.filter((g) => g.memberIds.includes(contract.creatorId)) + : [] + const groupsUserIsCreatorOf = groups + ? groups.filter((g) => g.creatorId === contract.creatorId) + : [] + + // Priorities for which group the contract belongs to: + // In order of created most recently + // Group that the contract owner created + // Group the contract owner is a member of + // Any group the contract is in + const groupToDisplay = + groupsUserIsCreatorOf.length > 0 + ? groupsUserIsCreatorOf[0] + : groupsUserIsMemberOf.length > 0 + ? groupsUserIsMemberOf[0] + : groups + ? groups[0] + : undefined return ( @@ -153,14 +176,15 @@ export function ContractDetails(props: { )} {!disabled && } - {/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/} - {groups && groups.length > 0 && ( + {groupToDisplay ? ( - + - {groups[0].name} + {groupToDisplay.name} + ) : ( +
)} {(!!closeTime || !!resolvedDate) && ( diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 6ad7237a..834af5ec 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -9,6 +9,7 @@ import { useRouter } from 'next/router' import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' +import { uniq } from 'lodash' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props @@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { await updateGroup(group, { name, about, - memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)], + memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]), }) setIsSubmitting(false) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index e6ee217d..b81155d1 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -9,7 +9,7 @@ import { TextButton } from 'web/components/text-button' import { Group } from 'common/group' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' -import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' +import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups' import { firebaseLogin } from 'web/lib/firebase/users' import { GroupLink } from 'web/pages/groups' @@ -93,7 +93,7 @@ export function JoinOrLeaveGroupButton(props: { : false const onJoinGroup = () => { if (!currentUser) return - joinGroup(group, currentUser.id) + addUserToGroup(group, currentUser.id) } const onLeaveGroup = () => { if (!currentUser) return diff --git a/web/components/layout/modal.tsx b/web/components/layout/modal.tsx index d61a38dd..7a320f24 100644 --- a/web/components/layout/modal.tsx +++ b/web/components/layout/modal.tsx @@ -1,13 +1,15 @@ import { Fragment, ReactNode } from 'react' import { Dialog, Transition } from '@headlessui/react' +import clsx from 'clsx' // From https://tailwindui.com/components/application-ui/overlays/modals export function Modal(props: { children: ReactNode open: boolean setOpen: (open: boolean) => void + className?: string }) { - const { children, open, setOpen } = props + const { children, open, setOpen, className } = props return ( @@ -45,7 +47,12 @@ export function Modal(props: { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > -
+
{children}
diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 69e8cfab..796f5dae 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -14,17 +14,17 @@ type Tab = { export function Tabs(props: { tabs: Tab[] defaultIndex?: number - className?: string + labelClassName?: string onClick?: (tabTitle: string, index: number) => void }) { - const { tabs, defaultIndex, className, onClick } = props + const { tabs, defaultIndex, labelClassName, onClick } = props const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0) const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case return ( - + {activeTab?.content} + ) } diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 402f5e12..8c3ceb02 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -254,7 +254,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
{memberItems.map((item) => ( diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index ac9fe8fd..ccacca04 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -258,7 +258,7 @@ export function UserPage(props: { {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( user - ) + return await Promise.all(group.memberIds.map(getUser)) } export const useGroupsWithContract = (contractId: string | undefined) => { diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 506849ad..04a5bd44 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -102,10 +102,13 @@ export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { console.error(`Group not found: ${groupSlug}`) return } - return await joinGroup(group, userId) + return await addUserToGroup(group, userId) } -export async function joinGroup(group: Group, userId: string): Promise { +export async function addUserToGroup( + group: Group, + userId: string +): Promise { const { memberIds } = group if (memberIds.includes(userId)) { return group @@ -125,3 +128,14 @@ export async function leaveGroup(group: Group, userId: string): Promise { await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) return newGroup } + +export async function addContractToGroup(group: Group, contractId: string) { + return await updateGroup(group, { + contractIds: uniq([...group.contractIds, contractId]), + }) + .then(() => group) + .catch((err) => { + console.error('error adding contract to group', err) + return err + }) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ebbb6f65..7d645b04 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -19,7 +19,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { getGroup, updateGroup } from 'web/lib/firebase/groups' +import { addContractToGroup, getGroup } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -186,9 +186,7 @@ export function NewContract(props: { isFree: false, }) if (result && selectedGroup) { - await updateGroup(selectedGroup, { - contractIds: [...selectedGroup.contractIds, result.id], - }) + await addContractToGroup(selectedGroup, result.id) } await router.push(contractPath(result as Contract)) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 3a3db14d..8a8bc4c1 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -4,12 +4,14 @@ import { Group } from 'common/group' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { listAllBets } from 'web/lib/firebase/bets' -import { Contract, listenForUserContracts } from 'web/lib/firebase/contracts' +import { Contract } from 'web/lib/firebase/contracts' import { groupPath, getGroupBySlug, getGroupContracts, updateGroup, + addContractToGroup, + addUserToGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' @@ -39,7 +41,6 @@ import React, { useEffect, useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { PlusIcon } from '@heroicons/react/outline' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' @@ -48,6 +49,7 @@ import ShortToggle from 'web/components/widgets/short-toggle' import { ShareIconButton } from 'web/components/share-icon-button' import { REFERRAL_AMOUNT } from 'common/user' import { SiteLink } from 'web/components/site-link' +import { ContractSearch } from 'web/components/contract-search' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -509,75 +511,46 @@ function GroupLeaderboards(props: { } function AddContractButton(props: { group: Group; user: User }) { - const { group, user } = props + const { group } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState(undefined) - const [query, setQuery] = useState('') - useEffect(() => { - return listenForUserContracts(user.id, (contracts) => { - setContracts(contracts.filter((c) => !group.contractIds.includes(c.id))) - }) - }, [group.contractIds, user.id]) - - async function addContractToGroup(contract: Contract) { - await updateGroup(group, { - ...group, - contractIds: [...group.contractIds, contract.id], - }) + async function addContractToCurrentGroup(contract: Contract) { + await addContractToGroup(group, contract.id) setOpen(false) } - // TODO use find-active-contracts to sort by? - const matches = sortBy(contracts, [ - (contract) => -1 * contract.createdTime, - ]).filter( - (c) => - checkAgainstQuery(query, c.question) || - checkAgainstQuery(query, c.description) || - checkAgainstQuery(query, c.tags.flat().join(' ')) - ) - const debouncedQuery = debounce(setQuery, 50) return ( <> - - + +
Add a question to your group
- debouncedQuery(e.target.value)} - placeholder="Search your questions" - className="input input-bordered mb-4 w-full" - /> -
- {contracts ? ( - {}} - hasMore={false} - onContractClick={(contract) => { - addContractToGroup(contract) - }} - overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'} - hideQuickBet={true} - /> - ) : ( - - )} +
+
@@ -591,17 +564,11 @@ function JoinGroupButton(props: { const { group, user } = props function joinGroup() { if (user && !group.memberIds.includes(user.id)) { - toast.promise( - updateGroup(group, { - ...group, - memberIds: [...group.memberIds, user.id], - }), - { - loading: 'Joining group...', - success: 'Joined group!', - error: "Couldn't join group", - } - ) + toast.promise(addUserToGroup(group, user.id), { + loading: 'Joining group...', + success: 'Joined group!', + error: "Couldn't join group", + }) } } return ( diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 08c99460..12cde274 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -64,7 +64,7 @@ export default function LinkPage() { <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9b0216b6..f3512c56 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -86,7 +86,7 @@ export default function Notifications() { <div className={'p-2 sm:p-4'}> <Title text={'Notifications'} className={'hidden md:block'} /> <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { From 2dce3e15a138bccc38063021003716127fcffa34 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 1 Jul 2022 17:03:26 -0600 Subject: [PATCH 02/10] Correct margin on tabs --- web/components/layout/tabs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 796f5dae..ac1c0fe3 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -23,8 +23,8 @@ export function Tabs(props: { return ( <> - <div className="border-b border-gray-200"> - <nav className="-mb-px mb-4 flex space-x-8" aria-label="Tabs"> + <div className="mb-4 border-b border-gray-200"> + <nav className="-mb-px flex space-x-8" aria-label="Tabs"> {tabs.map((tab, i) => ( <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> <a From cc52bff05e4202fa5f0e1962cf5bec266226f476 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 1 Jul 2022 16:45:05 -0700 Subject: [PATCH 03/10] fix functions/README formatting --- functions/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/functions/README.md b/functions/README.md index 031cc4fa..8013fb20 100644 --- a/functions/README.md +++ b/functions/README.md @@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ### For local development 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI -1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 0. `$ brew install java` - 1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` +1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): + + 1. `$ brew install java` + 2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options) 4. `$ mkdir firestore_export` to create a folder to store the exported database From 1a6afaf44fabdef277bb2837d7658554094469f3 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sat, 2 Jul 2022 14:37:59 -0500 Subject: [PATCH 04/10] Pseudo numeric market (#609) * create pseudo-numeric contracts * graph and bet panel for pseudo numeric * pseudo numeric market layout, quick betting * Estimated value * sell panel * fix graph * pseudo numeric resolution * bets tab * redemption for pseudo numeric markets * create log scale market, validation * log scale * create: initial value can't be min or max * don't allow log scale for ranges with negative values (b/c of problem with graph library) * prettier delenda est * graph: handle min value of zero * bet labeling * validation * prettier * pseudo numeric embeds * update disclaimer * validation * validation --- common/calculate.ts | 31 ++-- common/contract.ts | 22 ++- common/new-bet.ts | 3 +- common/new-contract.ts | 24 ++- common/payouts.ts | 15 +- common/pseudo-numeric.ts | 45 ++++++ functions/src/create-contract.ts | 30 +++- functions/src/emails.ts | 18 ++- functions/src/place-bet.ts | 5 +- functions/src/redeem-shares.ts | 6 +- functions/src/resolve-market.ts | 22 ++- web/components/bet-panel.tsx | 54 +++++-- web/components/bet-row.tsx | 5 +- web/components/bets-list.tsx | 39 ++++- web/components/contract/contract-card.tsx | 58 +++++++- web/components/contract/contract-overview.tsx | 18 ++- .../contract/contract-prob-graph.tsx | 51 +++++-- web/components/contract/quick-bet.tsx | 82 +++++++---- web/components/feed/feed-bets.tsx | 10 +- web/components/numeric-resolution-panel.tsx | 27 +++- web/components/outcome-label.tsx | 25 +++- web/components/sell-button.tsx | 12 +- web/components/sell-modal.tsx | 4 +- web/components/sell-row.tsx | 4 +- web/components/yes-no-selector.tsx | 6 +- web/pages/[username]/[contractSlug].tsx | 9 +- web/pages/create.tsx | 139 +++++++++++++----- web/pages/embed/[username]/[contractSlug].tsx | 15 +- 28 files changed, 623 insertions(+), 156 deletions(-) create mode 100644 common/pseudo-numeric.ts diff --git a/common/calculate.ts b/common/calculate.ts index a0574c10..482a0ccf 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -18,15 +18,24 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, +} from './contract' -export function getProbability(contract: BinaryContract) { +export function getProbability( + contract: BinaryContract | PseudoNumericContract +) { return contract.mechanism === 'cpmm-1' ? getCpmmProbability(contract.pool, contract.p) : getDpmProbability(contract.totalShares) } -export function getInitialProbability(contract: BinaryContract) { +export function getInitialProbability( + contract: BinaryContract | PseudoNumericContract +) { if (contract.initialProbability) return contract.initialProbability if (contract.mechanism === 'dpm-2' || (contract as any).totalShares) @@ -65,7 +74,9 @@ export function calculateShares( } export function calculateSaleAmount(contract: Contract, bet: Bet) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -87,7 +98,9 @@ export function getProbabilityAfterSale( } export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) { const outcome = contract.resolution if (!outcome) throw new Error('Contract not resolved') - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some( - (shares) => shares > 0 - ) + const hasShares = Object.values(totalShares).some((shares) => shares > 0) return { invested: Math.max(0, currentInvested), diff --git a/common/contract.ts b/common/contract.ts index dc91a20e..3a90d01f 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -2,9 +2,10 @@ import { Answer } from './answer' import { Fees } from './fees' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | FreeResponse | Numeric +export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyContractType = | (CPMM & Binary) + | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) @@ -33,7 +34,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market resolution?: string - resolutionProbability?: number, + resolutionProbability?: number closeEmailsSent?: number @@ -44,7 +45,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = { collectedFees: Fees } & T -export type BinaryContract = Contract & Binary +export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type DPMContract = Contract & DPM @@ -75,6 +77,18 @@ export type Binary = { resolution?: resolution } +export type PseudoNumeric = { + outcomeType: 'PSEUDO_NUMERIC' + min: number + max: number + isLogScale: boolean + resolutionValue?: number + + // same as binary market; map everything to probability + initialProbability: number + resolutionProbability?: number +} + export type FreeResponse = { outcomeType: 'FREE_RESPONSE' answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. @@ -94,7 +108,7 @@ export type Numeric = { export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const -export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const +export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/new-bet.ts b/common/new-bet.ts index ba799624..236c0908 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -14,6 +14,7 @@ import { DPMBinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from './contract' import { noFees } from './fees' import { addObjects } from './util/object' @@ -32,7 +33,7 @@ export type BetInfo = { export const getNewBinaryCpmmBetInfo = ( outcome: 'YES' | 'NO', amount: number, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..6c89c8c4 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -7,6 +7,7 @@ import { FreeResponse, Numeric, outcomeType, + PseudoNumeric, } from './contract' import { User } from './user' import { parseTags } from './util/parse' @@ -27,7 +28,8 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -37,6 +39,8 @@ export function getNewContract( const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) + : outcomeType === 'PSEUDO_NUMERIC' + ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) : getFreeAnswerProps(ante) @@ -111,6 +115,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { return system } +const getPseudoNumericCpmmProps = ( + initialProb: number, + ante: number, + min: number, + max: number, + isLogScale: boolean +) => { + const system: CPMM & PseudoNumeric = { + ...getBinaryCpmmProps(initialProb, ante), + outcomeType: 'PSEUDO_NUMERIC', + min, + max, + isLogScale, + } + + return system +} + const getFreeAnswerProps = (ante: number) => { const system: DPM & FreeResponse = { mechanism: 'dpm-2', diff --git a/common/payouts.ts b/common/payouts.ts index f2c8d271..1469cf4e 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,7 +1,12 @@ import { sumBy, groupBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' -import { Contract, CPMMBinaryContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + DPMContract, + PseudoNumericContract, +} from './contract' import { Fees } from './fees' import { LiquidityProvision } from './liquidity-provision' import { @@ -56,7 +61,11 @@ export const getPayouts = ( }, resolutionProbability?: number ): PayoutInfo => { - if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { + if ( + contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') + ) { return getFixedPayouts( outcome, contract, @@ -76,7 +85,7 @@ export const getPayouts = ( export const getFixedPayouts = ( outcome: string | undefined, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, bets: Bet[], liquidities: LiquidityProvision[], resolutionProbability?: number diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts new file mode 100644 index 00000000..9a322e35 --- /dev/null +++ b/common/pseudo-numeric.ts @@ -0,0 +1,45 @@ +import { BinaryContract, PseudoNumericContract } from './contract' +import { formatLargeNumber, formatPercent } from './util/format' + +export function formatNumericProbability( + p: number, + contract: PseudoNumericContract +) { + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) +} + +export const getMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return p + + const { min, max, isLogScale } = contract + + if (isLogScale) { + const logValue = p * Math.log10(max - min) + return 10 ** logValue + min + } + + return p * (max - min) + min + } + +export const getFormattedMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return formatPercent(p) + + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) + } + +export const getPseudoProbability = ( + value: number, + min: number, + max: number, + isLogScale = false +) => { + if (isLogScale) { + return Math.log10(value - min) / Math.log10(max - min) + } + + return (value - min) / (max - min) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index c9468fdc..0d78ab5c 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -28,6 +28,7 @@ import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' +import { getPseudoProbability } from '../../common/pseudo-numeric' const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), @@ -45,19 +46,31 @@ const binarySchema = z.object({ initialProb: z.number().min(1).max(99), }) +const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) + const numericSchema = z.object({ - min: z.number(), - max: z.number(), + min: finite(), + max: finite(), + initialValue: finite(), + isLogScale: z.boolean().optional(), }) export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb - if (outcomeType === 'NUMERIC') { - ;({ min, max } = validate(numericSchema, req.body)) - if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') + let min, max, initialProb, isLogScale + + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate( + numericSchema, + req.body + )) + if (max - min <= 0.01 || initialValue < min || initialValue > max) + throw new APIError(400, 'Invalid range.') + + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 } if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) @@ -121,7 +134,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + isLogScale ?? false ) if (ante) await chargeUser(user.id, ante, true) @@ -130,7 +144,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const providerId = user.id - if (outcomeType === 'BINARY') { + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) .doc() diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1ba8ca96..40e8900c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -6,8 +6,13 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatMoney, formatPercent } from '../../common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from '../../common/util/format' import { getValueFromBucket } from '../../common/calculate-dpm' +import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -101,6 +106,17 @@ const toDisplayResolution = ( return display || resolution } + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + const { resolutionValue } = contract + + return resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? getProbability(contract), + contract + ) + } + if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 06d27668..b6c7d267 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -70,7 +70,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { const { outcome } = validate(binarySchema, req.body) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { + } else if ( + (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + mechanism == 'cpmm-1' + ) { const { outcome } = validate(binarySchema, req.body) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index bdd3ab94..67922a65 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -16,7 +16,11 @@ export const redeemShares = async (userId: string, contractId: string) => { return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') + const { mechanism, outcomeType } = contract + if ( + !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || + mechanism !== 'cpmm-1' + ) return { status: 'success' } const betsSnap = await transaction.get( diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index ee78dfec..f8976cb3 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -27,7 +27,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(RESOLUTIONS), - probabilityInt: z.number().gte(0).lt(100).optional(), + probabilityInt: z.number().gte(0).lte(100).optional(), }) const freeResponseSchema = z.union([ @@ -39,7 +39,7 @@ const freeResponseSchema = z.union([ resolutions: z.array( z.object({ answer: z.number().int().nonnegative(), - pct: z.number().gte(0).lt(100), + pct: z.number().gte(0).lte(100), }) ), }), @@ -53,7 +53,19 @@ const numericSchema = z.object({ value: z.number().optional(), }) +const pseudoNumericSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), + }), +]) + const opts = { secrets: ['MAILGUN_KEY'] } + export const resolvemarket = newEndpoint(opts, async (req, auth) => { const { contractId } = validate(bodySchema, req.body) const userId = auth.uid @@ -221,12 +233,18 @@ const sendResolutionEmails = async ( function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract + if (outcomeType === 'NUMERIC') { return { ...validate(numericSchema, body), resolutions: undefined, probabilityInt: undefined, } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } } else if (outcomeType === 'FREE_RESPONSE') { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 73055872..f76117b9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' -import { BinaryContract, CPMMBinaryContract } from 'common/contract' +import { + BinaryContract, + CPMMBinaryContract, + PseudoNumericContract, +} from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -35,6 +39,7 @@ import { getCpmmProbability, getCpmmLiquidityFee, } from 'common/calculate-cpmm' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveShares } from './use-save-shares' import { SignUpPrompt } from './sign-up-prompt' @@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device' import { track } from 'web/lib/service/analytics' export function BetPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props @@ -81,7 +86,7 @@ export function BetPanel(props: { } export function BetPanelSwitcher(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' @@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: { }) { const { contract, className, title, selected, onBetSuccess } = props - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) @@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: { <Row className="items-center justify-between gap-2"> <div> You have {formatWithCommas(floorShares)}{' '} - <BinaryOutcomeLabel outcome={sharesOutcome} /> shares + {isPseudoNumeric ? ( + <PseudoNumericOutcomeLabel outcome={sharesOutcome} /> + ) : ( + <BinaryOutcomeLabel outcome={sharesOutcome} /> + )}{' '} + shares </div> {tradeType === 'BUY' && ( @@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: { } function BuyPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { const { contract, user, selected, onBuySuccess } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) @@ -302,6 +314,9 @@ function BuyPanel(props: { : 0) )} ${betChoice ?? 'YES'} shares` : undefined + + const format = getFormattedMappedValue(contract) + return ( <> <YesNoSelector @@ -309,6 +324,7 @@ function BuyPanel(props: { btnClassName="flex-1" selected={betChoice} onSelect={(choice) => onBetChoice(choice)} + isPseudoNumeric={isPseudoNumeric} /> <div className="my-3 text-left text-sm text-gray-500">Amount</div> <BuyAmountInput @@ -323,11 +339,13 @@ function BuyPanel(props: { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <div className="text-gray-500">Probability</div> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> <div> - {formatPercent(initialProb)} + {format(initialProb)} <span className="mx-2">→</span> - {formatPercent(resultProb)} + {format(resultProb)} </div> </Row> @@ -340,6 +358,8 @@ function BuyPanel(props: { <br /> payout if{' '} <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> </> + ) : isPseudoNumeric ? ( + 'Max payout' ) : ( <> Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> @@ -389,7 +409,7 @@ function BuyPanel(props: { } export function SellPanel(props: { - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' @@ -488,6 +508,10 @@ export function SellPanel(props: { } } + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const format = getFormattedMappedValue(contract) + return ( <> <AmountInput @@ -511,11 +535,13 @@ export function SellPanel(props: { <span className="text-neutral">{formatMoney(saleValue)}</span> </Row> <Row className="items-center justify-between"> - <div className="text-gray-500">Probability</div> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> <div> - {formatPercent(initialProb)} + {format(initialProb)} <span className="mx-2">→</span> - {formatPercent(resultProb)} + {format(resultProb)} </div> </Row> </Col> diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 9621f7a9..ae5e0b00 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' import { BetPanelSwitcher } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' @@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -32,6 +32,7 @@ export default function BetRow(props: { return ( <> <YesNoSelector + isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'} className={clsx('justify-end', className)} btnClassName={clsx('btn-sm w-24', btnClassName)} onSelect={(choice) => { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index f41f89b6..b8fb7d31 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -40,6 +41,7 @@ import { import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' +import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { SellSharesModal } from './sell-modal' @@ -366,6 +368,7 @@ export function BetsSummary(props: { const { contract, isYourBets, className } = props const { resolution, closeTime, outcomeType, mechanism } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isCpmm = mechanism === 'cpmm-1' const isClosed = closeTime && Date.now() > closeTime @@ -427,6 +430,25 @@ export function BetsSummary(props: { </div> </Col> </> + ) : isPseudoNumeric ? ( + <> + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Payout if {'>='} {formatLargeNumber(contract.max)} + </div> + <div className="whitespace-nowrap"> + {formatMoney(yesWinnings)} + </div> + </Col> + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Payout if {'<='} {formatLargeNumber(contract.min)} + </div> + <div className="whitespace-nowrap"> + {formatMoney(noWinnings)} + </div> + </Col> + </> ) : ( <Col> <div className="whitespace-nowrap text-sm text-gray-500"> @@ -507,13 +529,15 @@ export function ContractBetsTable(props: { const { isResolved, mechanism, outcomeType } = contract const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <div className={clsx('overflow-x-auto', className)}> {amountRedeemed > 0 && ( <> <div className="pl-2 text-sm text-gray-500"> - {amountRedeemed} YES shares and {amountRedeemed} NO shares + {amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '} + {amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares automatically redeemed for {formatMoney(amountRedeemed)}. </div> <Spacer h={4} /> @@ -541,7 +565,7 @@ export function ContractBetsTable(props: { )} {!isCPMM && !isResolved && <th>Payout if chosen</th>} <th>Shares</th> - <th>Probability</th> + {!isPseudoNumeric && <th>Probability</th>} <th>Date</th> </tr> </thead> @@ -585,6 +609,7 @@ function BetRow(props: { const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const saleAmount = saleBet?.sale?.amount @@ -628,14 +653,18 @@ function BetRow(props: { truncate="short" /> )} + {isPseudoNumeric && + ' than ' + formatNumericProbability(bet.probAfter, contract)} </td> <td>{formatMoney(Math.abs(amount))}</td> {!isCPMM && !isNumeric && <td>{saleDisplay}</td>} {!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>} <td>{formatWithCommas(Math.abs(shares))}</td> - <td> - {formatPercent(probBefore)} → {formatPercent(probAfter)} - </td> + {!isPseudoNumeric && ( + <td> + {formatPercent(probBefore)} → {formatPercent(probAfter)} + </td> + )} <td>{dayjs(createdTime).format('MMM D, h:mma')}</td> </tr> ) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 87239465..c6cda43c 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -9,6 +9,7 @@ import { BinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from 'common/contract' import { AnswerLabel, @@ -16,7 +17,11 @@ import { CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' -import { getOutcomeProbability, getTopAnswer } from 'common/calculate' +import { + getOutcomeProbability, + getProbability, + getTopAnswer, +} from 'common/calculate' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' @@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' export function ContractCard(props: { contract: Contract @@ -131,6 +137,13 @@ export function ContractCard(props: { /> )} + {outcomeType === 'PSEUDO_NUMERIC' && ( + <PseudoNumericResolutionOrExpectation + className="items-center" + contract={contract} + /> + )} + {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation className="items-center" @@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: { {resolution === 'CANCEL' ? ( <CancelLabel /> ) : ( - <div className="text-blue-400">{resolutionValue}</div> + <div className="text-blue-400"> + {formatLargeNumber(resolutionValue)} + </div> )} </> ) : ( @@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: { </Col> ) } + +export function PseudoNumericResolutionOrExpectation(props: { + contract: PseudoNumericContract + className?: string +}) { + const { contract, className } = props + const { resolution, resolutionValue, resolutionProbability } = contract + const textColor = `text-blue-400` + + return ( + <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> + {resolution ? ( + <> + <div className={clsx('text-base text-gray-500')}>Resolved</div> + + {resolution === 'CANCEL' ? ( + <CancelLabel /> + ) : ( + <div className="text-blue-400"> + {resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? 0, + contract + )} + </div> + )} + </> + ) : ( + <> + <div className={clsx('text-3xl', textColor)}> + {formatNumericProbability(getProbability(contract), contract)} + </div> + <div className={clsx('text-base', textColor)}>expected</div> + </> + )} + </Col> + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a68f37be..897bef04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -11,6 +11,7 @@ import { FreeResponseResolutionOrChance, BinaryResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' import BetRow from '../bet-row' @@ -32,6 +33,7 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <Col className={clsx('mb-6', className)}> @@ -49,6 +51,13 @@ export const ContractOverview = (props: { /> )} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + )} + {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation contract={contract} @@ -61,6 +70,11 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + {tradingAllowed(contract) && <BetRow contract={contract} />} + </Row> + ) : isPseudoNumeric ? ( + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && <BetRow contract={contract} />} </Row> ) : ( @@ -86,7 +100,9 @@ export const ContractOverview = (props: { /> </Col> <Spacer h={4} /> - {isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '} + {(isBinary || isPseudoNumeric) && ( + <ContractProbGraph contract={contract} bets={bets} /> + )}{' '} {outcomeType === 'FREE_RESPONSE' && ( <AnswersGraph contract={contract} bets={bets} /> )} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 7386d748..a9d26e2e 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -5,16 +5,20 @@ import dayjs from 'dayjs' import { memo } from 'react' import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' +import { getMappedValue } from 'common/pseudo-numeric' +import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract bets: Bet[] height?: number }) { const { contract, height } = props - const { resolutionTime, closeTime } = contract + const { resolutionTime, closeTime, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) @@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { contract.createdTime, ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const probs = [startProb, ...bets.map((bet) => bet.probAfter)] + + const f = getMappedValue(contract) + + const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const isClosed = !!closeTime && Date.now() > closeTime const latestTime = dayjs( @@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { times.push(latestTime.toDate()) probs.push(probs[probs.length - 1]) - const yTickValues = [0, 25, 50, 75, 100] + const quartiles = [0, 25, 50, 75, 100] + + const yTickValues = isBinary + ? quartiles + : quartiles.map((x) => x / 100).map(f) const { width } = useWindowSize() @@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints + const points: { x: Date; y: number }[] = [] + const s = isBinary ? 100 : 1 + const c = isLogScale && contract.min === 0 ? 1 : 0 + for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: probs[i] * 100 } + points[points.length] = { x: times[i], y: s * probs[i] + c } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: probs[i] * 100, + y: s * probs[i] + c, } } } } - const data = [{ id: 'Yes', data: points, color: '#11b981' }] + const data = [ + { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, + ] const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) + const formatter = isBinary + ? formatPercent + : (x: DatumValue) => formatLargeNumber(+x.valueOf()) + return ( <div className="w-full overflow-visible" @@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { > <ResponsiveLine data={data} - yScale={{ min: 0, max: 100, type: 'linear' }} - yFormat={formatPercent} + yScale={ + isBinary + ? { min: 0, max: 100, type: 'linear' } + : { + min: contract.min + c, + max: contract.max + c, + type: contract.isLogScale ? 'log' : 'linear', + } + } + yFormat={formatter} gridYValues={yTickValues} axisLeft={{ tickValues: yTickValues, - format: formatPercent, + format: formatter, }} xScale={{ type: 'time', diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 9ee8b165..adbcc456 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { getOutcomeProbability, getOutcomeProbabilityAfterBet, + getProbability, getTopAnswer, } from 'common/calculate' import { getExpectedValue } from 'common/calculate-dpm' @@ -25,18 +26,18 @@ import { useSaveShares } from '../use-save-shares' import { sellShares } from 'web/lib/firebase/api-call' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' const BET_SIZE = 10 export function QuickBet(props: { contract: Contract; user: User }) { const { contract, user } = props - const isCpmm = contract.mechanism === 'cpmm-1' + const { mechanism, outcomeType } = contract + const isCpmm = mechanism === 'cpmm-1' const userBets = useUserContractBets(user.id, contract.id) const topAnswer = - contract.outcomeType === 'FREE_RESPONSE' - ? getTopAnswer(contract) - : undefined + outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined // TODO: yes/no from useSaveShares doesn't work on numeric contracts const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( @@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) { topAnswer?.number.toString() || undefined ) const hasUpShares = - yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC') + yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC') const hasDownShares = - noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC' + noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC' const [upHover, setUpHover] = useState(false) const [downHover, setDownHover] = useState(false) @@ -130,25 +131,6 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } - function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { - if (contract.outcomeType === 'BINARY') { - return direction === 'UP' ? 'YES' : 'NO' - } - if (contract.outcomeType === 'FREE_RESPONSE') { - // TODO: Implement shorting of free response answers - if (direction === 'DOWN') { - throw new Error("Can't bet against free response answers") - } - return getTopAnswer(contract)?.id - } - if (contract.outcomeType === 'NUMERIC') { - // TODO: Ideally an 'UP' bet would be a uniform bet between [current, max] - throw new Error("Can't quick bet on numeric markets") - } - } - - const textColor = `text-${getColor(contract)}` - return ( <Col className={clsx( @@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) { <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', - upHover ? textColor : 'text-gray-400' + upHover ? 'text-green-500' : 'text-gray-400' )} /> ) : ( <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', - upHover ? textColor : 'text-gray-200' + upHover ? 'text-green-500' : 'text-gray-200' )} /> )} @@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { <QuickOutcomeView contract={contract} previewProb={previewProb} /> {/* Down bet triangle */} - {contract.outcomeType !== 'BINARY' ? ( + {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? ( <div> <div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div> <TriangleDownFillIcon @@ -254,6 +236,25 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) { ) } +function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { + const { outcomeType } = contract + + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { + return direction === 'UP' ? 'YES' : 'NO' + } + if (outcomeType === 'FREE_RESPONSE') { + // TODO: Implement shorting of free response answers + if (direction === 'DOWN') { + throw new Error("Can't bet against free response answers") + } + return getTopAnswer(contract)?.id + } + if (outcomeType === 'NUMERIC') { + // TODO: Ideally an 'UP' bet would be a uniform bet between [current, max] + throw new Error("Can't quick bet on numeric markets") + } +} + function QuickOutcomeView(props: { contract: Contract previewProb?: number @@ -261,9 +262,16 @@ function QuickOutcomeView(props: { }) { const { contract, previewProb, caption } = props const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + // If there's a preview prob, display that instead of the current prob const override = - previewProb === undefined ? undefined : formatPercent(previewProb) + previewProb === undefined + ? undefined + : isPseudoNumeric + ? formatNumericProbability(previewProb, contract) + : formatPercent(previewProb) + const textColor = `text-${getColor(contract)}` let display: string | undefined @@ -271,6 +279,9 @@ function QuickOutcomeView(props: { case 'BINARY': display = getBinaryProbPercent(contract) break + case 'PSEUDO_NUMERIC': + display = formatNumericProbability(getProbability(contract), contract) + break case 'NUMERIC': display = formatLargeNumber(getExpectedValue(contract)) break @@ -295,11 +306,15 @@ function QuickOutcomeView(props: { // Return a number from 0 to 1 for this contract // Resolved contracts are set to 1, for coloring purposes (even if NO) function getProb(contract: Contract) { - const { outcomeType, resolution } = contract - return resolution + const { outcomeType, resolution, resolutionProbability } = contract + return resolutionProbability + ? resolutionProbability + : resolution ? 1 : outcomeType === 'BINARY' ? getBinaryProb(contract) + : outcomeType === 'PSEUDO_NUMERIC' + ? getProbability(contract) : outcomeType === 'FREE_RESPONSE' ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') : outcomeType === 'NUMERIC' @@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) { export function getColor(contract: Contract) { // TODO: Try injecting a gradient here // return 'primary' - const { resolution } = contract + const { resolution, outcomeType } = contract + if (resolution) { return ( OUTCOME_TO_COLOR[resolution as resolution] ?? @@ -325,6 +341,8 @@ export function getColor(contract: Contract) { ) } + if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400' + if ((contract.closeTime ?? Infinity) < Date.now()) { return 'gray-400' } diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index ae22b4b8..2ffdae8e 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -7,13 +7,14 @@ import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' import { UsersIcon } from '@heroicons/react/solid' -import { formatMoney } from 'common/util/format' +import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React, { Fragment } from 'react' import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { JoinSpans } from 'web/components/join-spans' import { UserLink } from '../user-page' +import { formatNumericProbability } from 'common/pseudo-numeric' export function FeedBet(props: { contract: Contract @@ -75,6 +76,8 @@ export function BetStatusText(props: { hideOutcome?: boolean }) { const { bet, contract, bettor, isSelf, hideOutcome } = props + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const { amount, outcome, createdTime } = bet const bought = amount >= 0 ? 'bought' : 'sold' @@ -97,7 +100,10 @@ export function BetStatusText(props: { value={(bet as any).value} contract={contract} truncate="short" - /> + />{' '} + {isPseudoNumeric + ? ' than ' + formatNumericProbability(bet.probAfter, contract) + : ' at ' + formatPercent(bet.probAfter)} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index ebac68e5..cf111281 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users' import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' +import { NumericContract, PseudoNumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api-call' -import { NumericContract } from 'common/contract' import { BucketInput } from './bucket-input' +import { getPseudoProbability } from 'common/pseudo-numeric' export function NumericResolutionPanel(props: { creator: User - contract: NumericContract + contract: NumericContract | PseudoNumericContract className?: string }) { useEffect(() => { @@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: { }, []) const { contract, className } = props + const { min, max, outcomeType } = contract const [outcomeMode, setOutcomeMode] = useState< 'NUMBER' | 'CANCEL' | undefined @@ -32,15 +34,32 @@ export function NumericResolutionPanel(props: { const [error, setError] = useState<string | undefined>(undefined) const resolve = async () => { - const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL' + const finalOutcome = + outcomeMode === 'CANCEL' + ? 'CANCEL' + : outcomeType === 'PSEUDO_NUMERIC' + ? 'MKT' + : 'NUMBER' if (outcomeMode === undefined || finalOutcome === undefined) return setIsSubmitting(true) + const boundedValue = Math.max(Math.min(max, value ?? 0), min) + + const probabilityInt = + 100 * + getPseudoProbability( + boundedValue, + min, + max, + outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale + ) + try { const result = await resolveMarket({ outcome: finalOutcome, value, + probabilityInt, contractId: contract.id, }) console.log('resolved', outcome, 'result:', result) @@ -77,7 +96,7 @@ export function NumericResolutionPanel(props: { {outcomeMode === 'NUMBER' && ( <BucketInput - contract={contract} + contract={contract as any} isSubmitting={isSubmitting} onBucketChange={(v, o) => (setValue(v), setOutcome(o))} /> diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 054ebfd2..a94618e4 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -19,11 +19,15 @@ export function OutcomeLabel(props: { value?: number }) { const { outcome, contract, truncate, value } = props + const { outcomeType } = contract - if (contract.outcomeType === 'BINARY') + if (outcomeType === 'PSEUDO_NUMERIC') + return <PseudoNumericOutcomeLabel outcome={outcome as any} /> + + if (outcomeType === 'BINARY') return <BinaryOutcomeLabel outcome={outcome as any} /> - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return ( <span className="text-blue-500"> {value ?? getValueFromBucket(outcome, contract)} @@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) { return <CancelLabel /> } +export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) { + const { outcome } = props + + if (outcome === 'YES') return <HigherLabel /> + if (outcome === 'NO') return <LowerLabel /> + if (outcome === 'MKT') return <ProbLabel /> + return <CancelLabel /> +} + export function BinaryContractOutcomeLabel(props: { contract: BinaryContract resolution: resolution @@ -98,6 +111,14 @@ export function YesLabel() { return <span className="text-primary">YES</span> } +export function HigherLabel() { + return <span className="text-primary">HIGHER</span> +} + +export function LowerLabel() { + return <span className="text-red-400">LOWER</span> +} + export function NoLabel() { return <span className="text-red-400">NO</span> } diff --git a/web/components/sell-button.tsx b/web/components/sell-button.tsx index 2b3734a5..51c88442 100644 --- a/web/components/sell-button.tsx +++ b/web/components/sell-button.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useState } from 'react' @@ -7,7 +7,7 @@ import clsx from 'clsx' import { SellSharesModal } from './sell-modal' export function SellButton(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined sharesOutcome: 'YES' | 'NO' | undefined shares: number @@ -16,7 +16,8 @@ export function SellButton(props: { const { contract, user, sharesOutcome, shares, panelClassName } = props const userBets = useUserContractBets(user?.id, contract.id) const [showSellModal, setShowSellModal] = useState(false) - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' if (sharesOutcome && user && mechanism === 'cpmm-1') { return ( @@ -32,7 +33,10 @@ export function SellButton(props: { )} onClick={() => setShowSellModal(true)} > - {'Sell ' + sharesOutcome} + Sell{' '} + {isPseudoNumeric + ? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome] + : sharesOutcome} </button> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> {'(' + Math.floor(shares) + ' shares)'} diff --git a/web/components/sell-modal.tsx b/web/components/sell-modal.tsx index f5a1af67..63cf79b2 100644 --- a/web/components/sell-modal.tsx +++ b/web/components/sell-modal.tsx @@ -1,4 +1,4 @@ -import { CPMMBinaryContract } from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Bet } from 'common/bet' import { User } from 'common/user' import { Modal } from './layout/modal' @@ -11,7 +11,7 @@ import clsx from 'clsx' export function SellSharesModal(props: { className?: string - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx index 4fe2536f..a8cb2851 100644 --- a/web/components/sell-row.tsx +++ b/web/components/sell-row.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useState } from 'react' import { Col } from './layout/col' @@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares' import { SellSharesModal } from './sell-modal' export function SellRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined className?: string }) { diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index d040eba9..cac7bf74 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -12,6 +12,7 @@ export function YesNoSelector(props: { btnClassName?: string replaceYesButton?: React.ReactNode replaceNoButton?: React.ReactNode + isPseudoNumeric?: boolean }) { const { selected, @@ -20,6 +21,7 @@ export function YesNoSelector(props: { btnClassName, replaceNoButton, replaceYesButton, + isPseudoNumeric, } = props const commonClassNames = @@ -41,7 +43,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('YES')} > - Bet YES + {isPseudoNumeric ? 'HIGHER' : 'Bet YES'} </button> )} {replaceNoButton ? ( @@ -58,7 +60,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('NO')} > - Bet NO + {isPseudoNumeric ? 'LOWER' : 'Bet NO'} </button> )} </Row> diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 413de725..2576c2e3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -144,10 +144,12 @@ export function ContractPageContent( const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user - const hasSidePanel = (isBinary || isNumeric) && (allowTrade || allowResolve) + const hasSidePanel = + (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) const ogCardProps = getOpenGraphProps(contract) @@ -170,7 +172,7 @@ export function ContractPageContent( <BetPanel className="hidden xl:flex" contract={contract} /> ))} {allowResolve && - (isNumeric ? ( + (isNumeric || isPseudoNumeric ? ( <NumericResolutionPanel creator={user} contract={contract} /> ) : ( <ResolutionPanel creator={user} contract={contract} /> @@ -210,10 +212,11 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={bets} /> + {isNumeric && ( <AlertBox title="Warning" - text="Numeric markets were introduced as an experimental feature and are now deprecated." + text="Distributional numeric markets were introduced as an experimental feature and are now deprecated." /> )} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7d645b04..c7b8f02e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -85,8 +85,12 @@ export function NewContract(props: { const { creator, question, groupId } = props const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') const [initialProb] = useState(50) + const [minString, setMinString] = useState('') const [maxString, setMaxString] = useState('') + const [isLogScale, setIsLogScale] = useState(false) + const [initialValueString, setInitialValueString] = useState('') + const [description, setDescription] = useState('') // const [tagText, setTagText] = useState<string>(tag ?? '') // const tags = parseWordsAsTags(tagText) @@ -129,6 +133,18 @@ export function NewContract(props: { const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined + const initialValue = initialValueString + ? parseFloat(initialValueString) + : undefined + + const adjustIsLog = () => { + if (min === undefined || max === undefined) return + const lengthDiff = Math.log10(max - min) + if (lengthDiff > 2) { + setIsLogScale(true) + } + } + // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') @@ -145,13 +161,16 @@ export function NewContract(props: { // closeTime must be in the future closeTime && closeTime > Date.now() && - (outcomeType !== 'NUMERIC' || + (outcomeType !== 'PSEUDO_NUMERIC' || (min !== undefined && max !== undefined && + initialValue !== undefined && isFinite(min) && isFinite(max) && min < max && - max - min > 0.01)) + max - min > 0.01 && + min < initialValue && + initialValue < max)) function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') @@ -175,6 +194,8 @@ export function NewContract(props: { closeTime, min, max, + initialValue, + isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, tags: category ? [category] : undefined, }) @@ -220,6 +241,7 @@ export function NewContract(props: { choicesMap={{ 'Yes / No': 'BINARY', 'Free response': 'FREE_RESPONSE', + Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} @@ -232,38 +254,89 @@ export function NewContract(props: { <Spacer h={6} /> - {outcomeType === 'NUMERIC' && ( - <div className="form-control items-start"> - <label className="label gap-2"> - <span className="mb-1">Range</span> - <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> - </label> + {outcomeType === 'PSEUDO_NUMERIC' && ( + <> + <div className="form-control mb-2 items-start"> + <label className="label gap-2"> + <span className="mb-1">Range</span> + <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> + </label> - <Row className="gap-2"> - <input - type="number" - className="input input-bordered" - placeholder="MIN" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setMinString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={minString ?? ''} - /> - <input - type="number" - className="input input-bordered" - placeholder="MAX" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setMaxString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={maxString} - /> - </Row> - </div> + <Row className="gap-2"> + <input + type="number" + className="input input-bordered" + placeholder="MIN" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setMinString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={minString ?? ''} + /> + <input + type="number" + className="input input-bordered" + placeholder="MAX" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setMaxString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={maxString} + /> + </Row> + + {!(min !== undefined && min < 0) && ( + <Row className="mt-1 ml-2 mb-2 items-center"> + <span className="mr-2 text-sm">Log scale</span>{' '} + <input + type="checkbox" + checked={isLogScale} + onChange={() => setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> + </Row> + )} + + {min !== undefined && max !== undefined && min >= max && ( + <div className="mt-2 mb-2 text-sm text-red-500"> + The maximum value must be greater than the minimum. + </div> + )} + </div> + <div className="form-control mb-2 items-start"> + <label className="label gap-2"> + <span className="mb-1">Initial value</span> + <InfoTooltip text="The starting value for this market. Should be in between min and max values." /> + </label> + + <Row className="gap-2"> + <input + type="number" + className="input input-bordered" + placeholder="Initial value" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setInitialValueString(e.target.value)} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={initialValueString ?? ''} + /> + </Row> + + {initialValue !== undefined && + min !== undefined && + max !== undefined && + min < max && + (initialValue <= min || initialValue >= max) && ( + <div className="mt-2 mb-2 text-sm text-red-500"> + Initial value must be in between {min} and {max}.{' '} + </div> + )} + </div> + </> )} <div className="form-control max-w-[265px] items-start"> diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 98bf37b2..93439be7 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -7,6 +7,7 @@ import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { ContractDetails } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' @@ -79,6 +80,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { question, outcomeType } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const href = `https://${DOMAIN}${contractPath(contract)}` @@ -110,13 +112,18 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( <Row className="items-center gap-4"> - {/* this fails typechecking, but it doesn't explode because we will - never */} - <BetRow contract={contract as any} betPanelClassName="scale-75" /> + <BetRow contract={contract} betPanelClassName="scale-75" /> <BinaryResolutionOrChance contract={contract} /> </Row> )} + {isPseudoNumeric && ( + <Row className="items-center gap-4"> + <BetRow contract={contract} betPanelClassName="scale-75" /> + <PseudoNumericResolutionOrExpectation contract={contract} /> + </Row> + )} + {outcomeType === 'FREE_RESPONSE' && ( <FreeResponseResolutionOrChance contract={contract} @@ -133,7 +140,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { </div> <div className="mx-1" style={{ paddingBottom }}> - {isBinary && ( + {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} bets={bets} From 218b18254cf33577369a3b2dbb2ee75145789ae2 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 2 Jul 2022 15:46:32 -0400 Subject: [PATCH 05/10] add liquidity: support pseudo numeric markets --- functions/src/add-liquidity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 34d3f7c6..eca0a056 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( const contract = contractSnap.data() as Contract if ( contract.mechanism !== 'cpmm-1' || - contract.outcomeType !== 'BINARY' + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') ) return { status: 'error', message: 'Invalid contract' } From 18b87581916ace525dc256a176b12b0d9c96b886 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 13:26:42 -0700 Subject: [PATCH 06/10] Remove code for obsolete feed updater backend jobs (#607) * Remove code for obsolete feed updater backend jobs * Kill two more obsolete guys --- functions/package.json | 1 - functions/src/call-cloud-function.ts | 17 -- functions/src/fetch.ts | 9 - functions/src/keep-awake.ts | 25 --- functions/src/scripts/update-feed.ts | 53 ------ functions/src/update-feed.ts | 220 ------------------------ functions/src/update-recommendations.ts | 70 -------- yarn.lock | 29 +--- 8 files changed, 1 insertion(+), 423 deletions(-) delete mode 100644 functions/src/call-cloud-function.ts delete mode 100644 functions/src/fetch.ts delete mode 100644 functions/src/keep-awake.ts delete mode 100644 functions/src/scripts/update-feed.ts delete mode 100644 functions/src/update-feed.ts delete mode 100644 functions/src/update-recommendations.ts diff --git a/functions/package.json b/functions/package.json index eb6c7151..ed12b4e7 100644 --- a/functions/package.json +++ b/functions/package.json @@ -23,7 +23,6 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", - "fetch": "1.1.0", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/call-cloud-function.ts b/functions/src/call-cloud-function.ts deleted file mode 100644 index 35191343..00000000 --- a/functions/src/call-cloud-function.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as admin from 'firebase-admin' - -import fetch from './fetch' - -export const callCloudFunction = (functionName: string, data: unknown = {}) => { - const projectId = admin.instanceId().app.options.projectId - - const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}` - - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data }), - }).then((response) => response.json()) -} diff --git a/functions/src/fetch.ts b/functions/src/fetch.ts deleted file mode 100644 index 1b54dc6c..00000000 --- a/functions/src/fetch.ts +++ /dev/null @@ -1,9 +0,0 @@ -let fetchRequest: typeof fetch - -try { - fetchRequest = fetch -} catch { - fetchRequest = require('node-fetch') -} - -export default fetchRequest diff --git a/functions/src/keep-awake.ts b/functions/src/keep-awake.ts deleted file mode 100644 index 00799e65..00000000 --- a/functions/src/keep-awake.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as functions from 'firebase-functions' - -import { callCloudFunction } from './call-cloud-function' - -export const keepAwake = functions.pubsub - .schedule('every 1 minutes') - .onRun(async () => { - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - - await sleep(30) - - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - }) - -const sleep = (seconds: number) => { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)) -} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts deleted file mode 100644 index c5cba142..00000000 --- a/functions/src/scripts/update-feed.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -initAdmin() - -import { getValues } from '../utils' -import { User } from '../../../common/user' -import { batchedWaitAll } from '../../../common/util/promise' -import { Contract } from '../../../common/contract' -import { updateWordScores } from '../update-recommendations' -import { computeFeed } from '../update-feed' -import { getFeedContracts, getTaggedContracts } from '../get-feed-data' -import { CATEGORY_LIST } from '../../../common/categories' - -const firestore = admin.firestore() - -async function updateFeed() { - console.log('Updating feed') - - const contracts = await getValues<Contract>(firestore.collection('contracts')) - const feedContracts = await getFeedContracts() - const users = await getValues<User>( - firestore.collection('users').where('username', '==', 'JamesGrugett') - ) - - await batchedWaitAll( - users.map((user) => async () => { - console.log('Updating recs for', user.username) - await updateWordScores(user, contracts) - console.log('Updating feed for', user.username) - await computeFeed(user, feedContracts) - }) - ) - - console.log('Updating feed categories!') - - await batchedWaitAll( - users.map((user) => async () => { - for (const category of CATEGORY_LIST) { - const contracts = await getTaggedContracts(category) - const feed = await computeFeed(user, contracts) - await firestore - .collection(`private-users/${user.id}/cache`) - .doc(`feed-${category}`) - .set({ feed }) - } - }) - ) -} - -if (require.main === module) { - updateFeed().then(() => process.exit()) -} diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts deleted file mode 100644 index f19fda92..00000000 --- a/functions/src/update-feed.ts +++ /dev/null @@ -1,220 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { logInterpolation } from '../../common/util/math' -import { DAY_MS } from '../../common/util/time' -import { - getProbability, - getOutcomeProbability, - getTopAnswer, -} from '../../common/calculate' -import { User } from '../../common/user' -import { - getContractScore, - MAX_FEED_CONTRACTS, -} from '../../common/recommended-contracts' -import { callCloudFunction } from './call-cloud-function' -import { - getFeedContracts, - getRecentBetsAndComments, - getTaggedContracts, -} from './get-feed-data' -import { CATEGORY_LIST } from '../../common/categories' - -const firestore = admin.firestore() - -const BATCH_SIZE = 30 -const MAX_BATCHES = 50 - -const getUserBatches = async () => { - const users = shuffle(await getValues<User>(firestore.collection('users'))) - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += BATCH_SIZE) { - userBatches.push(users.slice(i, i + BATCH_SIZE)) - } - - console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length) - - return userBatches.slice(0, MAX_BATCHES) -} - -export const updateFeed = functions.pubsub - .schedule('every 60 minutes') - .onRun(async () => { - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map((users) => - callCloudFunction('updateFeedBatch', { users }) - ) - ) - - console.log('updating category feed') - - await Promise.all( - CATEGORY_LIST.map((category) => - callCloudFunction('updateCategoryFeed', { - category, - }) - ) - ) - }) - -export const updateFeedBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - const contracts = await getFeedContracts() - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc('feed').set({ feed }) - ) - ) - } -) - -export const updateCategoryFeed = functions.https.onCall( - async (data: { category: string }) => { - const { category } = data - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map(async (users) => { - await callCloudFunction('updateCategoryFeedBatch', { - users, - category, - }) - }) - ) - } -) - -export const updateCategoryFeedBatch = functions.https.onCall( - async (data: { users: User[]; category: string }) => { - const { users, category } = data - const contracts = await getTaggedContracts(category) - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed }) - ) - ) - } -) - -const getNewFeeds = async (users: User[], contracts: Contract[]) => { - const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts))) - const contractIds = uniq(flatten(feeds).map((c) => c.id)) - const data = await Promise.all(contractIds.map(getRecentBetsAndComments)) - const dataByContractId = zipObject(contractIds, data) - return feeds.map((feed) => - feed.map((contract) => { - return { contract, ...dataByContractId[contract.id] } - }) - ) -} - -const getUserCacheCollection = (user: User) => - firestore.collection(`private-users/${user.id}/cache`) - -export const computeFeed = async (user: User, contracts: Contract[]) => { - const userCacheCollection = getUserCacheCollection(user) - - const [wordScores, lastViewedTime] = await Promise.all([ - getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), - getValue<{ [contractId: string]: number }>( - userCacheCollection.doc('lastViewTime') - ), - ]).then((dicts) => dicts.map((dict) => dict ?? {})) - - const scoredContracts = contracts.map((contract) => { - const score = scoreContract( - contract, - wordScores, - lastViewedTime[contract.id] - ) - return [contract, score] as [Contract, number] - }) - - const sortedContracts = sortBy( - scoredContracts, - ([_, score]) => score - ).reverse() - - // console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) - - return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c) -} - -function scoreContract( - contract: Contract, - wordScores: { [word: string]: number }, - viewTime: number | undefined -) { - const recommendationScore = getContractScore(contract, wordScores) - const activityScore = getActivityScore(contract, viewTime) - // const lastViewedScore = getLastViewedScore(viewTime) - return recommendationScore * activityScore -} - -function getActivityScore(contract: Contract, viewTime: number | undefined) { - const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract - const hasNewComments = - lastCommentTime && (!viewTime || lastCommentTime > viewTime) - const newCommentScore = hasNewComments ? 1 : 0.5 - - const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime) - const commentDaysAgo = timeSinceLastComment / DAY_MS - const commentTimeScore = - 0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo)) - - const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime) - const betDaysAgo = timeSinceLastBet / DAY_MS - const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo)) - - let prob = 0.5 - if (outcomeType === 'BINARY') { - prob = getProbability(contract) - } else if (outcomeType === 'FREE_RESPONSE') { - const topAnswer = getTopAnswer(contract) - if (topAnswer) - prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) - } - const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 - const probScore = 0.5 + frac * 0.5 - - const { volume24Hours, volume7Days } = contract - const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1) - const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume) - - const score = - newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore - - // Map score to [0.5, 1] since no recent activty is not a deal breaker. - const mappedScore = 0.5 + 0.5 * score - const newMappedScore = 0.7 + 0.3 * score - - const isNew = Date.now() < contract.createdTime + DAY_MS - return isNew ? newMappedScore : mappedScore -} - -// function getLastViewedScore(viewTime: number | undefined) { -// if (viewTime === undefined) { -// return 1 -// } - -// const daysAgo = (Date.now() - viewTime) / DAY_MS - -// if (daysAgo < 0.5) { -// const frac = logInterpolation(0, 0.5, daysAgo) -// return 0.5 + 0.25 * frac -// } - -// const frac = logInterpolation(0.5, 14, daysAgo) -// return 0.75 + 0.25 * frac -// } diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts deleted file mode 100644 index bc82291c..00000000 --- a/functions/src/update-recommendations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { Bet } from '../../common/bet' -import { User } from '../../common/user' -import { ClickEvent } from '../../common/tracking' -import { getWordScores } from '../../common/recommended-contracts' -import { batchedWaitAll } from '../../common/util/promise' -import { callCloudFunction } from './call-cloud-function' - -const firestore = admin.firestore() - -export const updateRecommendations = functions.pubsub - .schedule('every 24 hours') - .onRun(async () => { - const users = await getValues<User>(firestore.collection('users')) - - const batchSize = 100 - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += batchSize) { - userBatches.push(users.slice(i, i + batchSize)) - } - - await Promise.all( - userBatches.map((batch) => - callCloudFunction('updateRecommendationsBatch', { users: batch }) - ) - ) - }) - -export const updateRecommendationsBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - - const contracts = await getValues<Contract>( - firestore.collection('contracts') - ) - - await batchedWaitAll( - users.map((user) => () => updateWordScores(user, contracts)) - ) - } -) - -export const updateWordScores = async (user: User, contracts: Contract[]) => { - const [bets, viewCounts, clicks] = await Promise.all([ - getValues<Bet>( - firestore.collectionGroup('bets').where('userId', '==', user.id) - ), - - getValue<{ [contractId: string]: number }>( - firestore.doc(`private-users/${user.id}/cache/viewCounts`) - ), - - getValues<ClickEvent>( - firestore - .collection(`private-users/${user.id}/events`) - .where('type', '==', 'click') - ), - ]) - - const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets) - - const cachedCollection = firestore.collection( - `private-users/${user.id}/cache` - ) - await cachedCollection.doc('wordScores').set(wordScores) -} diff --git a/yarn.lock b/yarn.lock index 15cd3c51..c07d548f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3875,13 +3875,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -biskviit@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/biskviit/-/biskviit-1.0.1.tgz#037a0cd4b71b9e331fd90a1122de17dc49e420a7" - integrity sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w== - dependencies: - psl "^1.1.7" - bluebird@^3.7.1: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5237,13 +5230,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= - dependencies: - iconv-lite "~0.4.13" - end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -5817,14 +5803,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fetch@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fetch/-/fetch-1.1.0.tgz#0a8279f06be37f9f0ebb567560a30a480da59a2e" - integrity sha1-CoJ58Gvjf58Ou1Z1YKMKSA2lmi4= - dependencies: - biskviit "1.0.1" - encoding "0.1.12" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -6782,7 +6760,7 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.4.24, iconv-lite@~0.4.13: +iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -9151,11 +9129,6 @@ pseudomap@^1.0.1: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.7: - version "1.8.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" - integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" From 90d7f55c6d7b80cb6395276b157f93fd4451e99c Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 13:27:06 -0700 Subject: [PATCH 07/10] Fix backup DB job to actually backup most things, refactor (#605) * Make backup manually invokable and thereby testable * Add a shitload of missing stuff to our backups * Also backup follows as per James --- functions/src/backup-db.ts | 91 +++++++++++++++++----------- functions/src/scripts/backup-db.ts | 16 +++++ functions/src/scripts/script-init.ts | 19 +++--- 3 files changed, 81 insertions(+), 45 deletions(-) create mode 100644 functions/src/scripts/backup-db.ts diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index 5174f595..227c89e4 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -18,46 +18,63 @@ import * as functions from 'firebase-functions' import * as firestore from '@google-cloud/firestore' -const client = new firestore.v1.FirestoreAdminClient() +import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client' -const bucket = 'gs://manifold-firestore-backup' +export const backupDbCore = async ( + client: FirestoreAdminClient, + project: string, + bucket: string +) => { + const name = client.databasePath(project, '(default)') + const outputUriPrefix = `gs://${bucket}` + // Leave collectionIds empty to export all collections + // or set to a list of collection IDs to export, + // collectionIds: ['users', 'posts'] + // NOTE: Subcollections are not backed up by default + const collectionIds = [ + 'contracts', + 'groups', + 'private-users', + 'stripe-transactions', + 'transactions', + 'users', + 'bets', + 'comments', + 'follows', + 'followers', + 'answers', + 'txns', + 'manalinks', + 'liquidity', + 'stats', + 'cache', + 'latency', + 'views', + 'notifications', + 'portfolioHistory', + 'folds', + ] + return await client.exportDocuments({ name, outputUriPrefix, collectionIds }) +} export const backupDb = functions.pubsub .schedule('every 24 hours') - .onRun((_context) => { - const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT - if (projectId == null) { - throw new Error('No project ID environment variable set.') + .onRun(async (_context) => { + try { + const client = new firestore.v1.FirestoreAdminClient() + const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT + if (project == null) { + throw new Error('No project ID environment variable set.') + } + const responses = await backupDbCore( + client, + project, + 'manifold-firestore-backup' + ) + const response = responses[0] + console.log(`Operation Name: ${response['name']}`) + } catch (err) { + console.error(err) + throw new Error('Export operation failed') } - const databaseName = client.databasePath(projectId, '(default)') - - return client - .exportDocuments({ - name: databaseName, - outputUriPrefix: bucket, - // Leave collectionIds empty to export all collections - // or set to a list of collection IDs to export, - // collectionIds: ['users', 'posts'] - // NOTE: Subcollections are not backed up by default - collectionIds: [ - 'contracts', - 'groups', - 'private-users', - 'stripe-transactions', - 'users', - 'bets', - 'comments', - 'followers', - 'answers', - 'txns', - ], - }) - .then((responses) => { - const response = responses[0] - console.log(`Operation Name: ${response['name']}`) - }) - .catch((err) => { - console.error(err) - throw new Error('Export operation failed') - }) }) diff --git a/functions/src/scripts/backup-db.ts b/functions/src/scripts/backup-db.ts new file mode 100644 index 00000000..04c66438 --- /dev/null +++ b/functions/src/scripts/backup-db.ts @@ -0,0 +1,16 @@ +import * as firestore from '@google-cloud/firestore' +import { getServiceAccountCredentials } from './script-init' +import { backupDbCore } from '../backup-db' + +async function backupDb() { + const credentials = getServiceAccountCredentials() + const projectId = credentials.project_id + const client = new firestore.v1.FirestoreAdminClient({ credentials }) + const bucket = 'manifold-firestore-backup' + const resp = await backupDbCore(client, projectId, bucket) + console.log(`Operation: ${resp[0]['name']}`) +} + +if (require.main === module) { + backupDb().then(() => process.exit()) +} diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 8f65e4be..cc17a620 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => { } } -export const initAdmin = (env?: string) => { +export const getServiceAccountCredentials = (env?: string) => { env = env || getFirebaseActiveProject(process.cwd()) if (env == null) { - console.error( + throw new Error( "Couldn't find active Firebase project; did you do `firebase use <alias>?`" ) - return } const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const keyPath = process.env[envVar] if (keyPath == null) { - console.error( + throw new Error( `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` ) - return } - console.log(`Initializing connection to ${env} Firebase...`) /* eslint-disable-next-line @typescript-eslint/no-var-requires */ - const serviceAccount = require(keyPath) - admin.initializeApp({ + return require(keyPath) +} + +export const initAdmin = (env?: string) => { + const serviceAccount = getServiceAccountCredentials(env) + console.log(`Initializing connection to ${serviceAccount.project_id}...`) + return admin.initializeApp({ + projectId: serviceAccount.project_id, credential: admin.credential.cert(serviceAccount), }) } From 7dea9cbfa89336bcb2672800e4ac3418ac807280 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 16:24:03 -0700 Subject: [PATCH 08/10] Use `getAll` Firestore technology to improve some code (#612) --- functions/src/place-bet.ts | 5 +---- functions/src/sell-bet.ts | 10 +++++----- functions/src/sell-shares.ts | 5 ++--- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index b6c7d267..43906f3c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -41,10 +41,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { log('Inside main transaction.') const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) - const [contractSnap, userSnap] = await Promise.all([ - trans.get(contractDoc), - trans.get(userDoc), - ]) + const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') log('Loaded user and contract snapshots.') diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index b3362159..18df4536 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -21,11 +21,11 @@ export const sellbet = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) - const [contractSnap, userSnap, betSnap] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), - transaction.get(betDoc), - ]) + const [contractSnap, userSnap, betSnap] = await transaction.getAll( + contractDoc, + userDoc, + betDoc + ) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!betSnap.exists) throw new APIError(400, 'Bet not found.') diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 26374a16..a0c19f2c 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -24,9 +24,8 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [contractSnap, userSnap, userBets] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), + const [[contractSnap, userSnap], userBets] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') From 960f8a1b3d20e2c6a2829bbd97fb5ba56dad64e0 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Sun, 3 Jul 2022 20:18:12 +0100 Subject: [PATCH 09/10] Toggle weekly leaderboard and daily/weekly/alltime portfolio graph (#616) * Toggle weekly leaderboard and daily/weekly/alltime portfolio graph * Formatmoney for tooltip value --- .../portfolio/portfolio-value-graph.tsx | 3 ++- .../portfolio/portfolio-value-section.tsx | 15 +++++++++++---- web/components/user-page.tsx | 11 +++++++---- web/pages/leaderboards.tsx | 4 +++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index 558fc5f6..50a6b59a 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -52,7 +52,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { margin={{ top: 20, right: 28, bottom: 22, left: 60 }} xScale={{ type: 'time', - min: points[0].x, + min: points[0]?.x, max: endDate, }} yScale={{ @@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { enableGridY={true} enableSlices="x" animate={false} + yFormat={(value) => formatMoney(+value)} ></ResponsiveLine> </div> ) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index a992e87e..55260bb5 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -13,7 +13,7 @@ export const PortfolioValueSection = memo( }) { const { portfolioHistory } = props const lastPortfolioMetrics = last(portfolioHistory) - const [portfolioPeriod] = useState<Period>('allTime') + const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { return <div> No portfolio history data yet </div> @@ -33,9 +33,16 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - { - //TODO: enable day/week/monthly as data becomes available - } + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">All time</option> + <option value="weekly">Weekly</option> + <option value="daily">Daily</option> + </select> </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index ccacca04..d72a2a16 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -38,6 +38,7 @@ import { FollowButton } from './follow-button' import { PortfolioMetrics } from 'common/user' import { ReferralsButton } from 'web/components/referrals-button' import { GroupsButton } from 'web/components/groups/groups-button' +import { PortfolioValueSection } from './portfolio/portfolio-value-section' export function UserLink(props: { name: string @@ -75,7 +76,9 @@ export function UserPage(props: { 'loading' ) const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') - const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([]) + const [portfolioHistory, setUsersPortfolioHistory] = useState< + PortfolioMetrics[] + >([]) const [commentsByContract, setCommentsByContract] = useState< Map<Contract, Comment[]> | 'loading' >('loading') @@ -297,9 +300,9 @@ export function UserPage(props: { title: 'Bets', content: ( <div> - { - // TODO: add portfolio-value-section here - } + <PortfolioValueSection + portfolioHistory={portfolioHistory} + /> <BetsList user={user} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 44c0a65b..f306493b 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -67,7 +67,9 @@ export default function Leaderboards(props: { <Col className="mx-4 items-center gap-10 lg:flex-row"> {!isLoading ? ( <> - {period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available + {period === 'allTime' || + period == 'weekly' || + period === 'daily' ? ( //TODO: show other periods once they're available <Leaderboard title="🏅 Top bettors" users={topTradersState} From 8fdc44f7f3bd2eaa28bd3448b98c421b02abeda3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 3 Jul 2022 15:37:22 -0400 Subject: [PATCH 10/10] Switch to firebase dev before serving firebase emulators --- functions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/package.json b/functions/package.json index ed12b4e7..ee7bc92d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,7 +12,7 @@ "start": "yarn shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", - "serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", + "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",