Daily profit 💰 (#1023)

* Daily profit client side

* Filter out those where profit rounds to 0

* Tabs to spaces
This commit is contained in:
James Grugett 2022-10-11 00:32:55 -05:00 committed by GitHub
parent 0ec15ff2f8
commit 70b2b14f80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 298 additions and 60 deletions

View File

@ -266,6 +266,8 @@ export const calculateMetricsByContract = (
}) })
} }
export type ContractMetrics = ReturnType<typeof calculateMetricsByContract>[number]
const calculatePeriodProfit = ( const calculatePeriodProfit = (
contract: CPMMBinaryContract, contract: CPMMBinaryContract,
bets: Bet[], bets: Bet[],

View File

@ -178,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) {
}) })
} }
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const { resolution } = contract const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1' const isCpmm = contract.mechanism === 'cpmm-1'

View File

@ -44,6 +44,10 @@ service cloud.firestore {
allow read; allow read;
} }
match /{somePath=**}/contract-metrics/{contractId} {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{ match /{somePath=**}/challenges/{challengeId}{
allow read; allow read;
} }

View File

@ -1,7 +1,11 @@
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatLargeNumber, formatPercent } from 'common/util/format' import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import {
@ -17,6 +21,7 @@ import {
import { import {
AnswerLabel, AnswerLabel,
BinaryContractOutcomeLabel, BinaryContractOutcomeLabel,
BinaryOutcomeLabel,
CancelLabel, CancelLabel,
FreeResponseOutcomeLabel, FreeResponseOutcomeLabel,
} from '../outcome-label' } from '../outcome-label'
@ -29,7 +34,7 @@ import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { getColor, ProbBar, QuickBet } from './quick-bet' import { getColor, ProbBar, QuickBet } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user' import { useUser, useUserContractMetrics } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric' import { getMappedValue } from 'common/pseudo-numeric'
@ -37,6 +42,7 @@ import { Tooltip } from '../tooltip'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { ProbChange } from './prob-change-table' import { ProbChange } from './prob-change-table'
import { Card } from '../card' import { Card } from '../card'
import { ProfitBadgeMana } from '../profit-badge'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -390,11 +396,18 @@ export function PseudoNumericResolutionOrExpectation(props: {
export function ContractCardProbChange(props: { export function ContractCardProbChange(props: {
contract: CPMMContract contract: CPMMContract
noLinkAvatar?: boolean noLinkAvatar?: boolean
showPosition?: boolean
className?: string className?: string
}) { }) {
const { noLinkAvatar, className } = props const { noLinkAvatar, showPosition, className } = props
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
const user = useUser()
const metrics = useUserContractMetrics(user?.id, contract.id)
const dayMetrics = metrics && metrics.from && metrics.from.day
const outcome =
metrics && metrics.hasShares && metrics.totalShares.YES ? 'YES' : 'NO'
return ( return (
<Card className={clsx(className, 'mb-4')}> <Card className={clsx(className, 'mb-4')}>
<AvatarDetails <AvatarDetails
@ -411,6 +424,28 @@ export function ContractCardProbChange(props: {
</SiteLink> </SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} /> <ProbChange className="py-2 pr-4" contract={contract} />
</Row> </Row>
{showPosition && metrics && (
<Row
className={clsx(
'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm'
)}
>
<Row className="gap-1">
<div className="text-gray-500">Position</div>
{formatMoney(metrics.payout)}
<BinaryOutcomeLabel outcome={outcome} />
</Row>
{dayMetrics && (
<>
<Row className="items-center">
<div className="mr-1 text-gray-500">Daily profit</div>
<ProfitBadgeMana amount={dayMetrics.profit} />
</Row>
</>
)}
</Row>
)}
</Card> </Card>
) )
} }

View File

@ -81,6 +81,7 @@ export function ContractsGrid(props: {
<ContractCardProbChange <ContractCardProbChange
key={contract.id} key={contract.id}
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
showPosition
/> />
) : ( ) : (
<ContractCard <ContractCard

View File

@ -1,11 +1,70 @@
import clsx from 'clsx' import clsx from 'clsx'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { sortBy } from 'lodash' import { sortBy } from 'lodash'
import { filterDefined } from 'common/util/array'
import { ContractMetrics } from 'common/calculate-metrics'
import { CPMMBinaryContract, CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { ContractCardProbChange } from './contract-card' import { ContractCardProbChange } from './contract-card'
export function ProfitChangeTable(props: {
contracts: CPMMBinaryContract[]
metrics: ContractMetrics[]
}) {
const { contracts, metrics } = props
const contractProfit = metrics.map(
(m) => [m.contractId, m.from?.day.profit ?? 0] as const
)
const positiveProfit = sortBy(
contractProfit.filter(([, profit]) => profit > 0),
([, profit]) => profit
).reverse()
const positive = filterDefined(
positiveProfit.map(([contractId]) =>
contracts.find((c) => c.id === contractId)
)
)
const negativeProfit = sortBy(
contractProfit.filter(([, profit]) => profit < 0),
([, profit]) => profit
)
const negative = filterDefined(
negativeProfit.map(([contractId]) =>
contracts.find((c) => c.id === contractId)
)
)
if (positive.length === 0 && negative.length === 0)
return <div className="px-4 text-gray-500">None</div>
return (
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1">
{positive.map((contract) => (
<ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))}
</Col>
<Col className="flex-1">
{negative.map((contract) => (
<ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))}
</Col>
</Col>
)
}
export function ProbChangeTable(props: { export function ProbChangeTable(props: {
changes: CPMMContract[] | undefined changes: CPMMContract[] | undefined
full?: boolean full?: boolean
@ -39,12 +98,20 @@ export function ProbChangeTable(props: {
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row"> <Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1"> <Col className="flex-1">
{filteredPositiveChanges.map((contract) => ( {filteredPositiveChanges.map((contract) => (
<ContractCardProbChange key={contract.id} contract={contract} /> <ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))} ))}
</Col> </Col>
<Col className="flex-1"> <Col className="flex-1">
{filteredNegativeChanges.map((contract) => ( {filteredNegativeChanges.map((contract) => (
<ContractCardProbChange key={contract.id} contract={contract} /> <ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))} ))}
</Col> </Col>
</Col> </Col>

View File

@ -1,4 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ENV_CONFIG } from 'common/envs/constants'
export function ProfitBadge(props: { export function ProfitBadge(props: {
profitPercent: number profitPercent: number
@ -26,3 +27,24 @@ export function ProfitBadge(props: {
</span> </span>
) )
} }
export function ProfitBadgeMana(props: { amount: number; className?: string }) {
const { amount, className } = props
const colors =
amount > 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
const formatted =
ENV_CONFIG.moneyMoniker + (amount > 0 ? '+' : '') + amount.toFixed(0)
return (
<span
className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
colors,
className
)}
>
{formatted}
</span>
)
}

View File

@ -1,10 +1,18 @@
import { useContext } from 'react' import { useContext } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import {
import { useQueryClient } from 'react-query' useFirestoreDocumentData,
useFirestoreQueryData,
} from '@react-query-firebase/firestore'
import { useQuery, useQueryClient } from 'react-query'
import { doc, DocumentData } from 'firebase/firestore' import { doc, DocumentData } from 'firebase/firestore'
import { getUser, User, users } from 'web/lib/firebase/users' import { getUser, User, users } from 'web/lib/firebase/users'
import { AuthContext } from 'web/components/auth-context' import { AuthContext } from 'web/components/auth-context'
import { ContractMetrics } from 'common/calculate-metrics'
import { getUserContractMetricsQuery } from 'web/lib/firebase/contract-metrics'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { buildArray, filterDefined } from 'common/util/array'
import { CPMMBinaryContract } from 'common/contract'
export const useUser = () => { export const useUser = () => {
const authUser = useContext(AuthContext) const authUser = useContext(AuthContext)
@ -38,3 +46,45 @@ export const usePrefetchUsers = (userIds: string[]) => {
queryClient.prefetchQuery(['users', userId], () => getUser(userId)) queryClient.prefetchQuery(['users', userId], () => getUser(userId))
) )
} }
export const useUserContractMetricsByProfit = (
userId: string,
count: number
) => {
const positiveResult = useFirestoreQueryData<ContractMetrics>(
['contract-metrics-descending', userId, count],
getUserContractMetricsQuery(userId, count, 'desc')
)
const negativeResult = useFirestoreQueryData<ContractMetrics>(
['contract-metrics-ascending', userId, count],
getUserContractMetricsQuery(userId, count, 'asc')
)
const metrics = buildArray(positiveResult.data, negativeResult.data)
const contractIds = metrics.map((m) => m.contractId)
const contractResult = useQuery(['contracts', contractIds], () =>
Promise.all(contractIds.map(getContractFromId))
)
const contracts = contractResult.data
if (!positiveResult.data || !negativeResult.data || !contracts)
return undefined
const filteredContracts = filterDefined(contracts) as CPMMBinaryContract[]
const filteredMetrics = metrics.filter(
(m) => m.from && Math.abs(m.from.day.profit) >= 0.5
)
return { contracts: filteredContracts, metrics: filteredMetrics }
}
export const useUserContractMetrics = (userId = '_', contractId: string) => {
const result = useFirestoreDocumentData<DocumentData, ContractMetrics>(
['user-contract-metrics', userId, contractId],
doc(users, userId, 'contract-metrics', contractId)
)
if (userId === '_') return undefined
return result.data
}

View File

@ -0,0 +1,23 @@
import { ContractMetrics } from 'common/calculate-metrics'
import {
query,
limit,
Query,
collection,
orderBy,
where,
} from 'firebase/firestore'
import { db } from './init'
export function getUserContractMetricsQuery(
userId: string,
count: number,
sort: 'asc' | 'desc'
) {
return query(
collection(db, 'users', userId, 'contract-metrics'),
where('from.day.profit', sort === 'desc' ? '>' : '<', 0),
orderBy('from.day.profit', sort),
limit(count)
) as Query<ContractMetrics>
}

View File

@ -1,10 +1,12 @@
import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { ProfitChangeTable } from 'web/components/contract/prob-change-table'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user' import { useUser, useUserContractMetricsByProfit } from 'web/hooks/use-user'
import { DailyProfit } from './home'
export default function DailyMovers() { export default function DailyMovers() {
const user = useUser() const user = useUser()
@ -13,7 +15,10 @@ export default function DailyMovers() {
return ( return (
<Page> <Page>
<Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4"> <Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4">
<Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" /> <Row className="mt-4 items-start justify-between sm:mt-0">
<Title className="mx-4 !mb-0 !mt-0 sm:mx-0" text="Daily movers" />
<DailyProfit user={user} />
</Row>
{user && <ProbChangesWrapper userId={user.id} />} {user && <ProbChangesWrapper userId={user.id} />}
</Col> </Col>
</Page> </Page>
@ -23,9 +28,9 @@ export default function DailyMovers() {
function ProbChangesWrapper(props: { userId: string }) { function ProbChangesWrapper(props: { userId: string }) {
const { userId } = props const { userId } = props
const changes = useProbChanges({ bettorId: userId })?.filter( const data = useUserContractMetricsByProfit(userId, 50)
(c) => Math.abs(c.probChanges.day) >= 0.01
)
return <ProbChangeTable changes={changes} full /> if (!data) return <LoadingIndicator />
return <ProfitChangeTable contracts={data.contracts} metrics={data.metrics} />
} }

View File

@ -19,19 +19,22 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Sort } from 'web/components/contract-search' import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group' import { Group } from 'common/group'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import {
usePrivateUser,
useUser,
useUserContractMetricsByProfit,
} from 'web/hooks/use-user'
import { import {
useMemberGroupsSubscription, useMemberGroupsSubscription,
useTrendingGroups, useTrendingGroups,
} from 'web/hooks/use-group' } from 'web/hooks/use-group'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { ProfitChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes' import { ContractMetrics } from 'common/calculate-metrics'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
import { ContractsGrid } from 'web/components/contract/contracts-grid' import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { PillButton } from 'web/components/buttons/pill-button' import { PillButton } from 'web/components/buttons/pill-button'
@ -74,7 +77,11 @@ export default function Home() {
} }
}, [user, sections]) }, [user, sections])
const dailyMovers = useProbChanges({ bettorId: user?.id }) const contractMetricsByProfit = useUserContractMetricsByProfit(
user?.id ?? '_',
3
)
const trendingContracts = useTrendingContracts(6) const trendingContracts = useTrendingContracts(6)
const newContracts = useNewContracts(6) const newContracts = useNewContracts(6)
const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6) const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6)
@ -87,7 +94,7 @@ export default function Home() {
const isLoading = const isLoading =
!user || !user ||
!dailyMovers || !contractMetricsByProfit ||
!trendingContracts || !trendingContracts ||
!newContracts || !newContracts ||
!dailyTrendingContracts !dailyTrendingContracts
@ -118,7 +125,7 @@ export default function Home() {
score: trendingContracts, score: trendingContracts,
newest: newContracts, newest: newContracts,
'daily-trending': dailyTrendingContracts, 'daily-trending': dailyTrendingContracts,
'daily-movers': dailyMovers, 'daily-movers': contractMetricsByProfit,
})} })}
{groups && groupContracts && trendingGroups.length > 0 ? ( {groups && groupContracts && trendingGroups.length > 0 ? (
@ -184,7 +191,10 @@ export const getHomeItems = (sections: string[]) => {
function renderSections( function renderSections(
sections: { id: string; label: string }[], sections: { id: string; label: string }[],
sectionContracts: { sectionContracts: {
'daily-movers': CPMMBinaryContract[] 'daily-movers': {
contracts: CPMMBinaryContract[]
metrics: ContractMetrics[]
}
'daily-trending': CPMMBinaryContract[] 'daily-trending': CPMMBinaryContract[]
newest: CPMMBinaryContract[] newest: CPMMBinaryContract[]
score: CPMMBinaryContract[] score: CPMMBinaryContract[]
@ -193,13 +203,16 @@ function renderSections(
return ( return (
<> <>
{sections.map((s) => { {sections.map((s) => {
const { id, label } = s const { id, label } = s as {
const contracts = id: keyof typeof sectionContracts
sectionContracts[s.id as keyof typeof sectionContracts] label: string
if (id === 'daily-movers') {
return <DailyMoversSection key={id} contracts={contracts} />
} }
if (id === 'daily-movers') {
return <DailyMoversSection key={id} {...sectionContracts[id]} />
}
const contracts = sectionContracts[id]
if (id === 'daily-trending') { if (id === 'daily-trending') {
return ( return (
<SearchSection <SearchSection
@ -347,58 +360,39 @@ function GroupSection(props: {
) )
} }
function DailyMoversSection(props: { contracts: CPMMBinaryContract[] }) { function DailyMoversSection(props: {
const { contracts } = props contracts: CPMMBinaryContract[]
metrics: ContractMetrics[]
}) {
const { contracts, metrics } = props
const changes = contracts.filter((c) => Math.abs(c.probChanges.day) >= 0.01) if (contracts.length === 0) {
if (changes.length === 0) {
return null return null
} }
return ( return (
<Col className="gap-2"> <Col className="gap-2">
<SectionHeader label="Daily movers" href="/daily-movers" /> <SectionHeader label="Daily movers" href="/daily-movers" />
<ProbChangeTable changes={changes} /> <ProfitChangeTable contracts={contracts} metrics={metrics} />
</Col> </Col>
) )
} }
function DailyStats(props: { function DailyStats(props: { user: User | null | undefined }) {
user: User | null | undefined const { user } = props
className?: string
}) {
const { user, className } = props
const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
const privateUser = usePrivateUser() const privateUser = usePrivateUser()
const streaks = privateUser?.notificationPreferences?.betting_streaks ?? [] const streaks = privateUser?.notificationPreferences?.betting_streaks ?? []
const streaksHidden = streaks.length === 0 const streaksHidden = streaks.length === 0
let profit = 0
let profitPercent = 0
if (first && last) {
profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
profitPercent = profit / first.investmentValue
}
return ( return (
<Row className={'flex-shrink-0 gap-4'}> <Row className={'flex-shrink-0 gap-4'}>
<Col> <DailyProfit user={user} />
<div className="text-gray-500">Daily profit</div>
<Row className={clsx(className, 'items-center text-lg')}>
<span>{formatMoney(profit)}</span>{' '}
<ProfitBadge profitPercent={profitPercent * 100} />
</Row>
</Col>
{!streaksHidden && ( {!streaksHidden && (
<Col> <Col>
<div className="text-gray-500">Streak</div> <div className="text-gray-500">Streak</div>
<Row <Row
className={clsx( className={clsx(
className,
'items-center text-lg', 'items-center text-lg',
user && !hasCompletedStreakToday(user) && 'grayscale' user && !hasCompletedStreakToday(user) && 'grayscale'
)} )}
@ -411,6 +405,39 @@ function DailyStats(props: {
) )
} }
export function DailyProfit(props: { user: User | null | undefined }) {
const { user } = props
const contractMetricsByProfit = useUserContractMetricsByProfit(
user?.id ?? '_',
100
)
const profit = sum(
contractMetricsByProfit?.metrics.map((m) =>
m.from ? m.from.day.profit : 0
) ?? []
)
const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
let profitPercent = 0
if (first && last) {
// profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
profitPercent = profit / first.investmentValue
}
return (
<SiteLink className="flex flex-col" href="/daily-movers">
<div className="text-gray-500">Daily profit</div>
<Row className="items-center text-lg">
<span>{formatMoney(profit)}</span>{' '}
<ProfitBadge profitPercent={profitPercent * 100} />
</Row>
</SiteLink>
)
}
export function TrendingGroupsSection(props: { export function TrendingGroupsSection(props: {
user: User user: User
myGroups: Group[] myGroups: Group[]