diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index e3b8ea39..b27ac977 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { sortBy, sum, sumBy } from 'lodash' +import { last, sortBy, sum, sumBy } from 'lodash' import { calculatePayout } from './calculate' import { Bet } from './bet' import { Contract } from './contract' @@ -36,6 +36,33 @@ export const computeVolume = (contractBets: Bet[], since: number) => { ) } +const calculateProbChangeSince = (descendingBets: Bet[], since: number) => { + const newestBet = descendingBets[0] + if (!newestBet) return 0 + + const betBeforeSince = descendingBets.find((b) => b.createdTime < since) + + if (!betBeforeSince) { + const oldestBet = last(descendingBets) ?? newestBet + return newestBet.probAfter - oldestBet.probBefore + } + + return newestBet.probAfter - betBeforeSince.probAfter +} + +export const calculateProbChanges = (descendingBets: Bet[]) => { + const now = Date.now() + const yesterday = now - DAY_MS + const weekAgo = now - 7 * DAY_MS + const monthAgo = now - 30 * DAY_MS + + return { + day: calculateProbChangeSince(descendingBets, yesterday), + week: calculateProbChangeSince(descendingBets, weekAgo), + month: calculateProbChangeSince(descendingBets, monthAgo), + } +} + export const calculateCreatorVolume = (userContracts: Contract[]) => { const allTimeCreatorVolume = computeTotalPool(userContracts, 0) const monthlyCreatorVolume = computeTotalPool( @@ -89,12 +116,12 @@ const calculateProfitForPeriod = ( return currentProfit } - const startingProfit = calculateTotalProfit(startingPortfolio) + const startingProfit = calculatePortfolioProfit(startingPortfolio) return currentProfit - startingProfit } -const calculateTotalProfit = (portfolio: PortfolioMetrics) => { +export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits } @@ -102,7 +129,7 @@ export const calculateNewProfit = ( portfolioHistory: PortfolioMetrics[], newPortfolio: PortfolioMetrics ) => { - const allTimeProfit = calculateTotalProfit(newPortfolio) + const allTimeProfit = calculatePortfolioProfit(newPortfolio) const descendingPortfolio = sortBy( portfolioHistory, (p) => p.timestamp diff --git a/common/comment.ts b/common/comment.ts index c7f9b855..7ecbb6d4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,6 +1,6 @@ import type { JSONContent } from '@tiptap/core' -export type AnyCommentType = OnContract | OnGroup +export type AnyCommentType = OnContract | OnGroup | OnPost // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. @@ -20,19 +20,31 @@ export type Comment = { userAvatarUrl?: string } & T -type OnContract = { +export type OnContract = { commentType: 'contract' contractId: string - contractSlug: string - contractQuestion: string answerOutcome?: string betId?: string + + // denormalized from contract + contractSlug: string + contractQuestion: string + + // denormalized from bet + betAmount?: number + betOutcome?: string } -type OnGroup = { +export type OnGroup = { commentType: 'group' groupId: string } +export type OnPost = { + commentType: 'post' + postId: string +} + export type ContractComment = Comment export type GroupComment = Comment +export type PostComment = Comment diff --git a/common/contract.ts b/common/contract.ts index 5dc4b696..0d2a38ca 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -87,6 +87,12 @@ export type CPMM = { pool: { [outcome: string]: number } p: number // probability constant in y^p * n^(1-p) = k totalLiquidity: number // in M$ + prob: number + probChanges: { + day: number + week: number + month: number + } } export type Binary = { diff --git a/common/group.ts b/common/group.ts index 5c716dba..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,10 +12,6 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number - /** @deprecated - members and contracts now stored as subcollections*/ - memberIds?: string[] // Deprecated - /** @deprecated - members and contracts now stored as subcollections*/ - contractIds?: string[] // Deprecated } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 diff --git a/common/new-contract.ts b/common/new-contract.ts index 17b872ab..431f435e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { initialProbability: p, p, pool: pool, + prob: initialProb, + probChanges: { day: 0, week: 0, month: 0 }, } return system diff --git a/docs/docs/api.md b/docs/docs/api.md index c02a5141..e284abdf 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -54,6 +54,10 @@ Returns the authenticated user. Gets all groups, in no particular order. +Parameters: +- `availableToUserId`: Optional. if specified, only groups that the user can + join and groups they've already joined will be returned. + Requires no authorization. ### `GET /v0/groups/[slug]` @@ -62,12 +66,18 @@ Gets a group by its slug. Requires no authorization. -### `GET /v0/groups/by-id/[id]` +### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. Requires no authorization. +### `GET /v0/group/by-id/[id]/markets` + +Gets a group's markets by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. diff --git a/firestore.rules b/firestore.rules index bb493bb7..bf1aea3a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,7 +12,9 @@ service cloud.firestore { 'taowell@gmail.com', 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com', - 'iansphilips@gmail.com' + 'iansphilips@gmail.com', + 'd4vidchee@gmail.com', + 'federicoruizcassarino@gmail.com' ] } @@ -203,6 +205,10 @@ service cloud.firestore { .affectedKeys() .hasOnly(['name', 'content']); allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; + match /comments/{commentId} { + allow read; + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ; + } } } } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 663a7977..a36a8bca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions .doc(comment.betId) .get() bet = betSnapshot.data() as Bet - answer = contract.outcomeType === 'FREE_RESPONSE' && contract.answers ? contract.answers.find((answer) => answer.id === bet?.outcome) : undefined + + await change.ref.update({ + betOutcome: bet.outcome, + betAmount: bet.amount, + }) } const comments = await getValues( diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 404fda50..d98430c1 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { !isFinite(newP) || Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) ) { - throw new APIError(400, 'Bet too large for current liquidity pool.') + throw new APIError(400, 'Trade too large for current liquidity pool.') } const betDoc = contractDoc.collection('bets').doc() diff --git a/functions/src/scripts/denormalize-comment-bet-data.ts b/functions/src/scripts/denormalize-comment-bet-data.ts new file mode 100644 index 00000000..929626c3 --- /dev/null +++ b/functions/src/scripts/denormalize-comment-bet-data.ts @@ -0,0 +1,69 @@ +// Filling in the bet-based fields on comments. + +import * as admin from 'firebase-admin' +import { zip } from 'lodash' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { log } from '../utils' +import { Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getBetComments(transaction: Transaction) { + const allComments = await transaction.get( + firestore.collectionGroup('comments') + ) + const betComments = allComments.docs.filter((d) => d.get('betId')) + log(`Found ${betComments.length} comments associated with bets.`) + return betComments +} + +async function denormalize() { + let hasMore = true + while (hasMore) { + hasMore = await admin.firestore().runTransaction(async (trans) => { + const betComments = await getBetComments(trans) + const bets = await Promise.all( + betComments.map((doc) => + trans.get( + firestore + .collection('contracts') + .doc(doc.get('contractId')) + .collection('bets') + .doc(doc.get('betId')) + ) + ) + ) + log(`Found ${bets.length} bets associated with comments.`) + const mapping = zip(bets, betComments) + .map(([bet, comment]): DocumentCorrespondence => { + return [bet!, [comment!]] // eslint-disable-line + }) + .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs + + const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') + const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') + log(`Found ${amountDiffs.length} comments with mismatched amounts.`) + log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) + const diffs = amountDiffs.concat(outcomeDiffs) + diffs.slice(0, 500).forEach((d) => { + log(describeDiff(d)) + applyDiff(trans, d) + }) + if (diffs.length > 500) { + console.log(`Applying first 500 because of Firestore limit...`) + } + return diffs.length > 500 + }) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts index 952a0d55..fc402292 100644 --- a/functions/src/scripts/update-groups.ts +++ b/functions/src/scripts/update-groups.ts @@ -9,84 +9,84 @@ const getGroups = async () => { return groups.docs.map((doc) => doc.data() as Group) } -const createContractIdForGroup = async ( - groupId: string, - contractId: string -) => { - const firestore = admin.firestore() - const now = Date.now() - const contractDoc = await firestore - .collection('groups') - .doc(groupId) - .collection('groupContracts') - .doc(contractId) - .get() - if (!contractDoc.exists) - await firestore - .collection('groups') - .doc(groupId) - .collection('groupContracts') - .doc(contractId) - .create({ - contractId, - createdTime: now, - }) -} +// const createContractIdForGroup = async ( +// groupId: string, +// contractId: string +// ) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const contractDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .get() +// if (!contractDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .create({ +// contractId, +// createdTime: now, +// }) +// } -const createMemberForGroup = async (groupId: string, userId: string) => { - const firestore = admin.firestore() - const now = Date.now() - const memberDoc = await firestore - .collection('groups') - .doc(groupId) - .collection('groupMembers') - .doc(userId) - .get() - if (!memberDoc.exists) - await firestore - .collection('groups') - .doc(groupId) - .collection('groupMembers') - .doc(userId) - .create({ - userId, - createdTime: now, - }) -} +// const createMemberForGroup = async (groupId: string, userId: string) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const memberDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .get() +// if (!memberDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .create({ +// userId, +// createdTime: now, +// }) +// } + +// async function convertGroupFieldsToGroupDocuments() { +// const groups = await getGroups() +// for (const group of groups) { +// log('updating group', group.slug) +// const groupRef = admin.firestore().collection('groups').doc(group.id) +// const totalMembers = (await groupRef.collection('groupMembers').get()).size +// const totalContracts = (await groupRef.collection('groupContracts').get()) +// .size +// if ( +// totalMembers === group.memberIds?.length && +// totalContracts === group.contractIds?.length +// ) { +// log('group already converted', group.slug) +// continue +// } +// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 +// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 +// for (const contractId of group.contractIds?.slice( +// contractStart, +// group.contractIds?.length +// ) ?? []) { +// await createContractIdForGroup(group.id, contractId) +// } +// for (const userId of group.memberIds?.slice( +// membersStart, +// group.memberIds?.length +// ) ?? []) { +// await createMemberForGroup(group.id, userId) +// } +// } +// } // eslint-disable-next-line @typescript-eslint/no-unused-vars -async function convertGroupFieldsToGroupDocuments() { - const groups = await getGroups() - for (const group of groups) { - log('updating group', group.slug) - const groupRef = admin.firestore().collection('groups').doc(group.id) - const totalMembers = (await groupRef.collection('groupMembers').get()).size - const totalContracts = (await groupRef.collection('groupContracts').get()) - .size - if ( - totalMembers === group.memberIds?.length && - totalContracts === group.contractIds?.length - ) { - log('group already converted', group.slug) - continue - } - const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 - const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 - for (const contractId of group.contractIds?.slice( - contractStart, - group.contractIds?.length - ) ?? []) { - await createContractIdForGroup(group.id, contractId) - } - for (const userId of group.memberIds?.slice( - membersStart, - group.memberIds?.length - ) ?? []) { - await createMemberForGroup(group.id, userId) - } - } -} - async function updateTotalContractsAndMembers() { const groups = await getGroups() for (const group of groups) { @@ -101,9 +101,22 @@ async function updateTotalContractsAndMembers() { }) } } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function removeUnusedMemberAndContractFields() { + const groups = await getGroups() + for (const group of groups) { + log('removing member and contract ids', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + await groupRef.update({ + memberIds: admin.firestore.FieldValue.delete(), + contractIds: admin.firestore.FieldValue.delete(), + }) + } +} if (require.main === module) { initAdmin() // convertGroupFieldsToGroupDocuments() - updateTotalContractsAndMembers() + // updateTotalContractsAndMembers() + removeUnusedMemberAndContractFields() } diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index c6673969..430f3d33 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,9 +1,9 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last } from 'lodash' +import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' -import { Contract } from '../../common/contract' +import { Contract, CPMM } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' @@ -11,8 +11,10 @@ import { calculateCreatorVolume, calculateNewPortfolioMetrics, calculateNewProfit, + calculateProbChanges, computeVolume, } from '../../common/calculate-metrics' +import { getProbability } from '../../common/calculate' const firestore = admin.firestore() @@ -43,11 +45,29 @@ export async function updateMetricsCore() { .filter((contract) => contract.id) .map((contract) => { const contractBets = betsByContract[contract.id] ?? [] + const descendingBets = sortBy( + contractBets, + (bet) => bet.createdTime + ).reverse() + + let cpmmFields: Partial = {} + if (contract.mechanism === 'cpmm-1') { + const prob = descendingBets[0] + ? descendingBets[0].probAfter + : getProbability(contract) + + cpmmFields = { + prob, + probChanges: calculateProbChanges(descendingBets), + } + } + return { doc: firestore.collection('contracts').doc(contract.id), fields: { volume24Hours: computeVolume(contractBets, now - DAY_MS), volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + ...cpmmFields, }, } }) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 2469a636..f8e235b7 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -118,7 +118,9 @@ export function getHtml(parsedReq: ParsedRequest) { ? resolutionDiv : numericValue ? numericValueDiv - : probabilityDiv + : probability + ? probabilityDiv + : '' } diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 971a5496..9eff26ef 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -84,6 +84,7 @@ export function BuyAmountInput(props: { setError: (error: string | undefined) => void minimumAmount?: number disabled?: boolean + showSliderOnMobile?: boolean className?: string inputClassName?: string // Needed to focus the amount input @@ -94,6 +95,7 @@ export function BuyAmountInput(props: { onChange, error, setError, + showSliderOnMobile: showSlider, disabled, className, inputClassName, @@ -121,15 +123,28 @@ export function BuyAmountInput(props: { } return ( - + <> + + {showSlider && ( + onAmountChange(parseInt(e.target.value))} + className="range range-lg z-40 mb-2 xl:hidden" + step="5" + /> + )} + ) } diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index c5897056..6e54b3b8 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -120,7 +120,7 @@ export function AnswerBetPanel(props: {
- Bet on {isModal ? `"${answer.text}"` : 'this answer'} + Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
{!isModal && ( @@ -134,8 +134,9 @@ export function AnswerBetPanel(props: {
Amount - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} + {(betAmount ?? 0) > 10 && @@ -204,7 +206,7 @@ export function AnswerBetPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit trade'} + {isSubmitting ? 'Submitting...' : 'Submit'} ) : ( diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 38aeac0e..7e20e92e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { return ( - +
Add your answer