Precalculate and store current positions for users who make comments (#878)

This commit is contained in:
Marshall Polaris 2022-09-18 15:57:50 -07:00 committed by GitHub
parent e37b805b49
commit 58dcbaaf6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 204 additions and 105 deletions

View File

@ -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 { Bet, LimitBet } from './bet'
import { import {
calculateCpmmSale, calculateCpmmSale,
@ -255,3 +255,43 @@ export function getTopAnswer(
) )
return top?.answer 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 }
}

View File

@ -33,6 +33,11 @@ export type OnContract = {
// denormalized from bet // denormalized from bet
betAmount?: number betAmount?: number
betOutcome?: string betOutcome?: string
// denormalized based on betting history
commenterPositionProb?: number // binary only
commenterPositionShares?: number
commenterPositionOutcome?: string
} }
export type OnGroup = { export type OnGroup = {

View File

@ -5,6 +5,8 @@ import { getContract, getUser, getValues } from './utils'
import { ContractComment } from '../../common/comment' import { ContractComment } from '../../common/comment'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getLargestPosition } from '../../common/calculate'
import { maxBy } from 'lodash'
import { import {
createCommentOrAnswerOrUpdatedContractNotification, createCommentOrAnswerOrUpdatedContractNotification,
replied_users_info, replied_users_info,
@ -45,6 +47,32 @@ export const onCreateCommentOnContract = functions
.doc(contract.id) .doc(contract.id)
.update({ lastCommentTime, lastUpdatedTime: Date.now() }) .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 bet: Bet | undefined
let answer: Answer | undefined let answer: Answer | undefined
if (comment.answerOutcome) { if (comment.answerOutcome) {

View File

@ -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<string, DocumentSnapshot[]>()
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))
}

View File

@ -106,7 +106,6 @@ export function ContractTopTrades(props: {
contract={contract} contract={contract}
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
tips={tips[topCommentId]} tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
/> />
</div> </div>
<Spacer h={16} /> <Spacer h={16} />

View File

@ -85,7 +85,9 @@ export function ContractTabs(props: {
<div className={'mb-4 w-full border-b border-gray-200'} /> <div className={'mb-4 w-full border-b border-gray-200'} />
<ContractCommentsActivity <ContractCommentsActivity
contract={contract} contract={contract}
bets={generalBets} betsByCurrentUser={
user ? generalBets.filter((b) => b.userId === user.id) : []
}
comments={generalComments} comments={generalComments}
tips={tips} tips={tips}
user={user} user={user}
@ -95,7 +97,9 @@ export function ContractTabs(props: {
) : ( ) : (
<ContractCommentsActivity <ContractCommentsActivity
contract={contract} contract={contract}
bets={visibleBets} betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments} comments={comments}
tips={tips} tips={tips}
user={user} user={user}

View File

@ -73,13 +73,12 @@ export function ContractBetsActivity(props: {
export function ContractCommentsActivity(props: { export function ContractCommentsActivity(props: {
contract: Contract contract: Contract
bets: Bet[] betsByCurrentUser: Bet[]
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
user: User | null | undefined user: User | null | undefined
}) { }) {
const { bets, contract, comments, user, tips } = props const { betsByCurrentUser, contract, comments, user, tips } = props
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy( const topLevelComments = sortBy(
@ -92,7 +91,7 @@ export function ContractCommentsActivity(props: {
<ContractCommentInput <ContractCommentInput
className="mb-5" className="mb-5"
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
/> />
{topLevelComments.map((parent) => ( {topLevelComments.map((parent) => (
@ -106,8 +105,7 @@ export function ContractCommentsActivity(props: {
(c) => c.createdTime (c) => c.createdTime
)} )}
tips={tips} tips={tips}
bets={bets} betsByCurrentUser={betsByCurrentUser}
betsByUserId={betsByUserId}
commentsByUserId={commentsByUserId} commentsByUserId={commentsByUserId}
/> />
))} ))}
@ -136,7 +134,9 @@ export function FreeResponseContractCommentsActivity(props: {
}) })
.filter((answer) => answer != null) .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 commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_') const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_')
@ -157,7 +157,7 @@ export function FreeResponseContractCommentsActivity(props: {
(c) => c.createdTime (c) => c.createdTime
)} )}
tips={tips} tips={tips}
betsByUserId={betsByUserId} betsByCurrentUser={betsByCurrentUser}
commentsByUserId={commentsByUserId} commentsByUserId={commentsByUserId}
/> />
</div> </div>

View File

@ -27,7 +27,7 @@ export function FeedAnswerCommentGroup(props: {
answer: Answer answer: Answer
answerComments: ContractComment[] answerComments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
betsByUserId: Dictionary<Bet[]> betsByCurrentUser: Bet[]
commentsByUserId: Dictionary<ContractComment[]> commentsByUserId: Dictionary<ContractComment[]>
}) { }) {
const { const {
@ -35,7 +35,7 @@ export function FeedAnswerCommentGroup(props: {
contract, contract,
answerComments, answerComments,
tips, tips,
betsByUserId, betsByCurrentUser,
commentsByUserId, commentsByUserId,
user, user,
} = props } = props
@ -48,7 +48,6 @@ export function FeedAnswerCommentGroup(props: {
const router = useRouter() const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser const isFreeResponseContractPage = !!commentsByCurrentUser
const mostRecentCommentableBet = getMostRecentCommentableBet( const mostRecentCommentableBet = getMostRecentCommentableBet(
@ -166,7 +165,6 @@ export function FeedAnswerCommentGroup(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id]} tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={scrollAndOpenReplyInput}
/> />
))} ))}

View File

@ -3,7 +3,7 @@ import { ContractComment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' 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 { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -29,8 +29,7 @@ export function FeedCommentThread(props: {
threadComments: ContractComment[] threadComments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
parentComment: ContractComment parentComment: ContractComment
bets: Bet[] betsByCurrentUser: Bet[]
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]> commentsByUserId: Dictionary<ContractComment[]>
}) { }) {
const { const {
@ -38,8 +37,7 @@ export function FeedCommentThread(props: {
contract, contract,
threadComments, threadComments,
commentsByUserId, commentsByUserId,
bets, betsByCurrentUser,
betsByUserId,
tips, tips,
parentComment, parentComment,
} = props } = props
@ -64,17 +62,7 @@ export function FeedCommentThread(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id]} tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={scrollAndOpenReplyInput}
probAtCreatedTime={
contract.outcomeType === 'BINARY'
? minBy(bets, (bet) => {
return bet.createdTime < comment.createdTime
? comment.createdTime - bet.createdTime
: comment.createdTime
})?.probAfter
: undefined
}
/> />
))} ))}
{showReply && ( {showReply && (
@ -85,7 +73,7 @@ export function FeedCommentThread(props: {
/> />
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByCurrentUser) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
@ -104,22 +92,21 @@ export function FeedComment(props: {
contract: Contract contract: Contract
comment: ContractComment comment: ContractComment
tips: CommentTips tips: CommentTips
betsBySameUser: Bet[]
indent?: boolean indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: ContractComment) => void onReplyClick?: (comment: ContractComment) => void
}) { }) {
const { contract, comment, tips, indent, onReplyClick } = props
const { const {
contract, text,
comment, content,
tips, userUsername,
betsBySameUser, userName,
indent, userAvatarUrl,
probAtCreatedTime, commenterPositionProb,
onReplyClick, commenterPositionShares,
} = props commenterPositionOutcome,
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = createdTime,
comment } = comment
const betOutcome = comment.betOutcome const betOutcome = comment.betOutcome
let bought: string | undefined let bought: string | undefined
let money: string | undefined let money: string | undefined
@ -136,13 +123,6 @@ export function FeedComment(props: {
} }
}, [comment.id, router.asPath]) }, [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 ( return (
<Row <Row
id={comment.id} id={comment.id}
@ -167,14 +147,17 @@ export function FeedComment(props: {
username={userUsername} username={userUsername}
name={userName} name={userName}
/>{' '} />{' '}
{!comment.betId != null && {comment.betId == null &&
userPosition > 0 && commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && ( contract.outcomeType !== 'NUMERIC' && (
<> <>
{'is '} {'is '}
<CommentStatus <CommentStatus
prob={probAtCreatedTime} prob={commenterPositionProb}
outcome={outcome} outcome={commenterPositionOutcome}
contract={contract} contract={contract}
/> />
</> </>
@ -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) { function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId const isSelf = user?.id === userId