From 58dcbaaf6e23dee75c3f40106a37d19a3b642d85 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sun, 18 Sep 2022 15:57:50 -0700 Subject: [PATCH] Precalculate and store current positions for users who make comments (#878) --- common/calculate.ts | 42 ++++++- common/comment.ts | 5 + .../src/on-create-comment-on-contract.ts | 28 +++++ .../scripts/backfill-comment-position-data.ts | 92 +++++++++++++++ .../contract/contract-leaderboard.tsx | 1 - web/components/contract/contract-tabs.tsx | 8 +- web/components/feed/contract-activity.tsx | 16 +-- .../feed/feed-answer-comment-group.tsx | 6 +- web/components/feed/feed-comments.tsx | 111 ++++-------------- 9 files changed, 204 insertions(+), 105 deletions(-) create mode 100644 functions/src/scripts/backfill-comment-position-data.ts diff --git a/common/calculate.ts b/common/calculate.ts index e4c9ed07..5edf1211 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,4 +1,4 @@ -import { maxBy, sortBy, sum, sumBy } from 'lodash' +import { maxBy, partition, sortBy, sum, sumBy } from 'lodash' import { Bet, LimitBet } from './bet' import { calculateCpmmSale, @@ -255,3 +255,43 @@ export function getTopAnswer( ) return top?.answer } + +export function getLargestPosition(contract: Contract, userBets: Bet[]) { + let yesFloorShares = 0, + yesShares = 0, + noShares = 0, + noFloorShares = 0 + + if (userBets.length === 0) { + return null + } + if (contract.outcomeType === 'FREE_RESPONSE') { + const answerCounts: { [outcome: string]: number } = {} + for (const bet of userBets) { + if (bet.outcome) { + if (!answerCounts[bet.outcome]) { + answerCounts[bet.outcome] = bet.amount + } else { + answerCounts[bet.outcome] += bet.amount + } + } + } + const majorityAnswer = + maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? '' + return { + prob: undefined, + shares: answerCounts[majorityAnswer] || 0, + outcome: majorityAnswer, + } + } + + const [yesBets, noBets] = partition(userBets, (bet) => bet.outcome === 'YES') + yesShares = sumBy(yesBets, (bet) => bet.shares) + noShares = sumBy(noBets, (bet) => bet.shares) + yesFloorShares = Math.floor(yesShares) + noFloorShares = Math.floor(noShares) + + const shares = yesFloorShares || noFloorShares + const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO' + return { shares, outcome } +} diff --git a/common/comment.ts b/common/comment.ts index 7ecbb6d4..cdb62fd3 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -33,6 +33,11 @@ export type OnContract = { // denormalized from bet betAmount?: number betOutcome?: string + + // denormalized based on betting history + commenterPositionProb?: number // binary only + commenterPositionShares?: number + commenterPositionOutcome?: string } export type OnGroup = { diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 65e32dca..6bb568ff 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -5,6 +5,8 @@ import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' +import { getLargestPosition } from '../../common/calculate' +import { maxBy } from 'lodash' import { createCommentOrAnswerOrUpdatedContractNotification, replied_users_info, @@ -45,6 +47,32 @@ export const onCreateCommentOnContract = functions .doc(contract.id) .update({ lastCommentTime, lastUpdatedTime: Date.now() }) + const previousBetsQuery = await firestore + .collection('contracts') + .doc(contractId) + .collection('bets') + .where('createdTime', '<', comment.createdTime) + .get() + const previousBets = previousBetsQuery.docs.map((d) => d.data() as Bet) + const position = getLargestPosition( + contract, + previousBets.filter((b) => b.userId === comment.userId && !b.isAnte) + ) + if (position) { + const fields: { [k: string]: unknown } = { + commenterPositionShares: position.shares, + commenterPositionOutcome: position.outcome, + } + const previousProb = + contract.outcomeType === 'BINARY' + ? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter + : undefined + if (previousProb != null) { + fields.commenterPositionProb = previousProb + } + await change.ref.update(fields) + } + let bet: Bet | undefined let answer: Answer | undefined if (comment.answerOutcome) { diff --git a/functions/src/scripts/backfill-comment-position-data.ts b/functions/src/scripts/backfill-comment-position-data.ts new file mode 100644 index 00000000..eab54c55 --- /dev/null +++ b/functions/src/scripts/backfill-comment-position-data.ts @@ -0,0 +1,92 @@ +// Filling in historical bet positions on comments. + +// Warning: This just recalculates all of them, rather than trying to +// figure out which ones are out of date, since I'm using it to fill them +// in once in the first place. + +import { maxBy } from 'lodash' +import * as admin from 'firebase-admin' +import { filterDefined } from '../../../common/util/array' +import { Bet } from '../../../common/bet' +import { Comment } from '../../../common/comment' +import { Contract } from '../../../common/contract' +import { getLargestPosition } from '../../../common/calculate' +import { initAdmin } from './script-init' +import { DocumentSnapshot } from 'firebase-admin/firestore' +import { log, writeAsync } from '../utils' + +initAdmin() +const firestore = admin.firestore() + +async function getContractsById() { + const contracts = await firestore.collection('contracts').get() + const results = Object.fromEntries( + contracts.docs.map((doc) => [doc.id, doc.data() as Contract]) + ) + log(`Found ${contracts.size} contracts.`) + return results +} + +async function getCommentsByContractId() { + const comments = await firestore + .collectionGroup('comments') + .where('contractId', '!=', null) + .get() + const results = new Map() + comments.forEach((doc) => { + const contractId = doc.get('contractId') + const contractComments = results.get(contractId) || [] + contractComments.push(doc) + results.set(contractId, contractComments) + }) + log(`Found ${comments.size} comments on ${results.size} contracts.`) + return results +} + +// not in a transaction for speed -- may need to be run more than once +async function denormalize() { + const contractsById = await getContractsById() + const commentsByContractId = await getCommentsByContractId() + for (const [contractId, comments] of commentsByContractId.entries()) { + const betsQuery = await firestore + .collection('contracts') + .doc(contractId) + .collection('bets') + .get() + log(`Loaded ${betsQuery.size} bets for contract ${contractId}.`) + const bets = betsQuery.docs.map((d) => d.data() as Bet) + const updates = comments.map((doc) => { + const comment = doc.data() as Comment + const contract = contractsById[contractId] + const previousBets = bets.filter( + (b) => b.createdTime < comment.createdTime + ) + const position = getLargestPosition( + contract, + previousBets.filter((b) => b.userId === comment.userId && !b.isAnte) + ) + if (position) { + const fields: { [k: string]: unknown } = { + commenterPositionShares: position.shares, + commenterPositionOutcome: position.outcome, + } + const previousProb = + contract.outcomeType === 'BINARY' + ? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter + : undefined + if (previousProb != null) { + fields.commenterPositionProb = previousProb + } + return { doc: doc.ref, fields } + } else { + return undefined + } + }) + log(`Updating ${updates.length} comments.`) + await writeAsync(firestore, filterDefined(updates)) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index fec6744d..a863f1bf 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -106,7 +106,6 @@ export function ContractTopTrades(props: { contract={contract} comment={commentsById[topCommentId]} tips={tips[topCommentId]} - betsBySameUser={[betsById[topCommentId]]} /> diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index e1ee141e..939eb624 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -85,7 +85,9 @@ export function ContractTabs(props: {
b.userId === user.id) : [] + } comments={generalComments} tips={tips} user={user} @@ -95,7 +97,9 @@ export function ContractTabs(props: { ) : ( b.userId === user.id) : [] + } comments={comments} tips={tips} user={user} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index b8a003fa..b7789308 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -73,13 +73,12 @@ export function ContractBetsActivity(props: { export function ContractCommentsActivity(props: { contract: Contract - bets: Bet[] + betsByCurrentUser: Bet[] comments: ContractComment[] tips: CommentTipMap user: User | null | undefined }) { - const { bets, contract, comments, user, tips } = props - const betsByUserId = groupBy(bets, (bet) => bet.userId) + const { betsByCurrentUser, contract, comments, user, tips } = props const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const topLevelComments = sortBy( @@ -92,7 +91,7 @@ export function ContractCommentsActivity(props: { {topLevelComments.map((parent) => ( @@ -106,8 +105,7 @@ export function ContractCommentsActivity(props: { (c) => c.createdTime )} tips={tips} - bets={bets} - betsByUserId={betsByUserId} + betsByCurrentUser={betsByCurrentUser} commentsByUserId={commentsByUserId} /> ))} @@ -136,7 +134,9 @@ export function FreeResponseContractCommentsActivity(props: { }) .filter((answer) => answer != null) - const betsByUserId = groupBy(bets, (bet) => bet.userId) + const betsByCurrentUser = user + ? bets.filter((bet) => bet.userId === user.id) + : [] const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_') @@ -157,7 +157,7 @@ export function FreeResponseContractCommentsActivity(props: { (c) => c.createdTime )} tips={tips} - betsByUserId={betsByUserId} + betsByCurrentUser={betsByCurrentUser} commentsByUserId={commentsByUserId} />
diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 0535ac33..958b6d6d 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -27,7 +27,7 @@ export function FeedAnswerCommentGroup(props: { answer: Answer answerComments: ContractComment[] tips: CommentTipMap - betsByUserId: Dictionary + betsByCurrentUser: Bet[] commentsByUserId: Dictionary }) { const { @@ -35,7 +35,7 @@ export function FeedAnswerCommentGroup(props: { contract, answerComments, tips, - betsByUserId, + betsByCurrentUser, commentsByUserId, user, } = props @@ -48,7 +48,6 @@ export function FeedAnswerCommentGroup(props: { const router = useRouter() const answerElementId = `answer-${answer.id}` - const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const isFreeResponseContractPage = !!commentsByCurrentUser const mostRecentCommentableBet = getMostRecentCommentableBet( @@ -166,7 +165,6 @@ export function FeedAnswerCommentGroup(props: { contract={contract} comment={comment} tips={tips[comment.id]} - betsBySameUser={betsByUserId[comment.userId] ?? []} onReplyClick={scrollAndOpenReplyInput} /> ))} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f9cb205c..0eca8915 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -3,7 +3,7 @@ import { ContractComment } from 'common/comment' import { User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' -import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' +import { Dictionary } from 'lodash' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { useRouter } from 'next/router' @@ -29,8 +29,7 @@ export function FeedCommentThread(props: { threadComments: ContractComment[] tips: CommentTipMap parentComment: ContractComment - bets: Bet[] - betsByUserId: Dictionary + betsByCurrentUser: Bet[] commentsByUserId: Dictionary }) { const { @@ -38,8 +37,7 @@ export function FeedCommentThread(props: { contract, threadComments, commentsByUserId, - bets, - betsByUserId, + betsByCurrentUser, tips, parentComment, } = props @@ -64,17 +62,7 @@ export function FeedCommentThread(props: { contract={contract} comment={comment} tips={tips[comment.id]} - betsBySameUser={betsByUserId[comment.userId] ?? []} onReplyClick={scrollAndOpenReplyInput} - probAtCreatedTime={ - contract.outcomeType === 'BINARY' - ? minBy(bets, (bet) => { - return bet.createdTime < comment.createdTime - ? comment.createdTime - bet.createdTime - : comment.createdTime - })?.probAfter - : undefined - } /> ))} {showReply && ( @@ -85,7 +73,7 @@ export function FeedCommentThread(props: { /> void }) { + const { contract, comment, tips, indent, onReplyClick } = props const { - contract, - comment, - tips, - betsBySameUser, - indent, - probAtCreatedTime, - onReplyClick, - } = props - const { text, content, userUsername, userName, userAvatarUrl, createdTime } = - comment + text, + content, + userUsername, + userName, + userAvatarUrl, + commenterPositionProb, + commenterPositionShares, + commenterPositionOutcome, + createdTime, + } = comment const betOutcome = comment.betOutcome let bought: string | undefined let money: string | undefined @@ -136,13 +123,6 @@ export function FeedComment(props: { } }, [comment.id, router.asPath]) - // Only calculated if they don't have a matching bet - const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( - contract, - comment.createdTime, - comment.betId ? [] : betsBySameUser - ) - return ( {' '} - {!comment.betId != null && - userPosition > 0 && + {comment.betId == null && + commenterPositionProb != null && + commenterPositionOutcome != null && + commenterPositionShares != null && + commenterPositionShares > 0 && contract.outcomeType !== 'NUMERIC' && ( <> {'is '} @@ -310,56 +293,6 @@ export function ContractCommentInput(props: { ) } -function getBettorsLargestPositionBeforeTime( - contract: Contract, - createdTime: number, - bets: Bet[] -) { - let yesFloorShares = 0, - yesShares = 0, - noShares = 0, - noFloorShares = 0 - - const previousBets = bets.filter( - (prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte - ) - - if (contract.outcomeType === 'FREE_RESPONSE') { - const answerCounts: { [outcome: string]: number } = {} - for (const bet of previousBets) { - if (bet.outcome) { - if (!answerCounts[bet.outcome]) { - answerCounts[bet.outcome] = bet.amount - } else { - answerCounts[bet.outcome] += bet.amount - } - } - } - const majorityAnswer = - maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? '' - return { - userPosition: answerCounts[majorityAnswer] || 0, - outcome: majorityAnswer, - } - } - if (bets.length === 0) { - return { userPosition: 0, outcome: '' } - } - - const [yesBets, noBets] = partition( - previousBets ?? [], - (bet) => bet.outcome === 'YES' - ) - yesShares = sumBy(yesBets, (bet) => bet.shares) - noShares = sumBy(noBets, (bet) => bet.shares) - yesFloorShares = Math.floor(yesShares) - noFloorShares = Math.floor(noShares) - - const userPosition = yesFloorShares || noFloorShares - const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO' - return { userPosition, outcome } -} - function canCommentOnBet(bet: Bet, user?: User | null) { const { userId, createdTime, isRedemption } = bet const isSelf = user?.id === userId