From aa02166c3ebf9decfbde37ce56cde0a58b891084 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 11:30:28 -0800 Subject: [PATCH 01/21] Reenable loans and store on contract metrics --- backend/api/src/get-next-loan-amount.ts | 52 +++++++ backend/api/src/request-loan.ts | 128 ++++++------------ backend/api/src/routes.ts | 2 + .../update-user-portfolio-histories-core.ts | 35 ++++- common/src/api/schema.ts | 8 ++ common/src/loans.ts | 99 +++++++------- common/src/supabase/contracts.ts | 6 +- web/components/home/daily-loan.tsx | 22 ++- web/components/home/daily-stats.tsx | 2 + web/components/profile/loans-modal.tsx | 2 +- web/hooks/use-is-eligible-for-loans.ts | 7 +- web/next-env.d.ts | 2 +- 12 files changed, 200 insertions(+), 165 deletions(-) create mode 100644 backend/api/src/get-next-loan-amount.ts diff --git a/backend/api/src/get-next-loan-amount.ts b/backend/api/src/get-next-loan-amount.ts new file mode 100644 index 0000000000..c1c5b1d128 --- /dev/null +++ b/backend/api/src/get-next-loan-amount.ts @@ -0,0 +1,52 @@ +import { APIError, type APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' +import { getUser, log } from 'shared/utils' +import { PortfolioMetrics } from 'common/portfolio-metrics' +import { getUserLoanUpdates, isUserEligibleForLoan } from 'common/loans' +import { getUnresolvedContractMetricsContractsAnswers } from 'shared/update-user-portfolio-histories-core' +import { keyBy } from 'lodash' + +export const getNextLoanAmount: APIHandler<'get-next-loan-amount'> = async ( + _, + auth +) => { + const pg = createSupabaseDirectClient() + + const portfolioMetric = await pg.oneOrNone( + `select user_id, ts, investment_value, balance, total_deposits + from user_portfolio_history_latest + where user_id = $1`, + [auth.uid], + (r) => + ({ + userId: r.user_id as string, + timestamp: Date.parse(r.ts as string), + investmentValue: parseFloat(r.investment_value as string), + balance: parseFloat(r.balance as string), + totalDeposits: parseFloat(r.total_deposits as string), + } as PortfolioMetrics & { userId: string }) + ) + if (!portfolioMetric) { + return { amount: 0 } + } + log(`Loaded portfolio.`) + + if (!isUserEligibleForLoan(portfolioMetric)) { + return { amount: 0 } + } + + const user = await getUser(auth.uid) + if (!user) { + throw new APIError(404, `User ${auth.uid} not found`) + } + log(`Loaded user ${user.id}`) + + const { contracts, metricsByContract } = + await getUnresolvedContractMetricsContractsAnswers(pg, [user.id]) + log(`Loaded ${contracts.length} contracts.`) + + const contractsById = keyBy(contracts, 'id') + + const result = getUserLoanUpdates(metricsByContract, contractsById) + return { amount: result.payout } +} diff --git a/backend/api/src/request-loan.ts b/backend/api/src/request-loan.ts index 160b0af2c8..bbd45b71a8 100644 --- a/backend/api/src/request-loan.ts +++ b/backend/api/src/request-loan.ts @@ -1,38 +1,31 @@ import { APIError, type APIHandler } from './helpers/endpoint' import { createSupabaseDirectClient, - pgp, SupabaseTransaction, } from 'shared/supabase/init' import { createLoanIncomeNotification } from 'shared/create-notification' -import { User } from 'common/user' -import { Contract } from 'common/contract' -import { log } from 'shared/utils' -import { Bet } from 'common/bet' +import { getUser, log } from 'shared/utils' import { PortfolioMetrics } from 'common/portfolio-metrics' -import { groupBy, uniq } from 'lodash' import { getUserLoanUpdates, isUserEligibleForLoan } from 'common/loans' import * as dayjs from 'dayjs' import * as utc from 'dayjs/plugin/utc' import * as timezone from 'dayjs/plugin/timezone' +import { bulkUpdateContractMetrics } from 'shared/helpers/user-contract-metrics' dayjs.extend(utc) dayjs.extend(timezone) import { LoanTxn } from 'common/txn' import { runTxnFromBank } from 'shared/txn/run-txn' - -// TODO: we don't store loans on the contract bets anymore, they're now stored on the user contract metrics. -// TODO: Before reenabling, move any loan writes to user_contract_metrics -const LOANS_DIABLED = true +import { filterDefined } from 'common/util/array' +import { getUnresolvedContractMetricsContractsAnswers } from 'shared/update-user-portfolio-histories-core' +import { keyBy } from 'lodash' export const requestloan: APIHandler<'request-loan'> = async (_, auth) => { - if (LOANS_DIABLED) throw new APIError(500, 'Loans are disabled') const pg = createSupabaseDirectClient() const portfolioMetric = await pg.oneOrNone( `select user_id, ts, investment_value, balance, total_deposits - from user_portfolio_history - where user_id = $1 - order by ts desc limit 1`, + from user_portfolio_history_latest + where user_id = $1`, [auth.uid], (r) => ({ @@ -52,81 +45,42 @@ export const requestloan: APIHandler<'request-loan'> = async (_, auth) => { throw new APIError(400, `User ${auth.uid} is not eligible for a loan`) } - const user = await pg.oneOrNone( - `select data from users where id = $1 limit 1`, - [auth.uid], - (r) => r.data - ) + const user = await getUser(auth.uid) if (!user) { throw new APIError(404, `User ${auth.uid} not found`) } log(`Loaded user ${user.id}`) - const bets = await pg.map( - ` - select contract_bets.data from contract_bets - join contracts on contract_bets.contract_id = contracts.id - where contracts.resolution is null - and contract_bets.user_id = $1 - order by contract_bets.created_time - `, - [auth.uid], - (r) => r.data - ) - log(`Loaded ${bets.length} bets.`) - - const contracts = await pg.map( - `select data from contracts - where contracts.resolution is null - and contracts.id = any($1) - `, - [uniq(bets.map((b) => b.contractId))], - (r) => r.data - ) + const { contracts, metricsByContract } = + await getUnresolvedContractMetricsContractsAnswers(pg, [user.id]) log(`Loaded ${contracts.length} contracts.`) - const contractsById = Object.fromEntries( - contracts.map((contract) => [contract.id, contract]) - ) - const betsByUser = groupBy(bets, (bet) => bet.userId) + const contractsById = keyBy(contracts, 'id') - const userContractBets = groupBy( - betsByUser[user.id] ?? [], - (b) => b.contractId - ) - - const result = getUserLoanUpdates(userContractBets, contractsById) + const result = getUserLoanUpdates(metricsByContract, contractsById) const { updates, payout } = result if (payout < 1) { throw new APIError(400, `User ${auth.uid} is not eligible for a loan`) } - - return await pg.tx(async (tx) => { - await payUserLoan(user.id, payout, tx) - await createLoanIncomeNotification(user, payout) - - const values = updates - .map((update) => - pgp.as.format(`($1, $2, $3)`, [ - update.contractId, - update.betId, - update.loanTotal, - ]) + const updatedMetrics = filterDefined( + updates.map((update) => { + const metric = metricsByContract[update.contractId]?.find( + (m) => m.answerId == update.answerId ) - .join(',\n') - - await tx.none( - `update contract_bets c - set - data = c.data || jsonb_build_object('loanAmount', v.loan_total) - from (values ${values}) as v(contract_id, bet_id, loan_total) - where c.contract_id = v.contract_id and c.bet_id = v.bet_id` - ) - - log(`Paid out ${payout} to user ${user.id}.`) - - return { payout } + if (!metric) return undefined + return { + ...metric, + loan: (metric.loan ?? 0) + update.newLoan, + } + }) + ) + await pg.tx(async (tx) => { + await payUserLoan(user.id, payout, tx) + await bulkUpdateContractMetrics(updatedMetrics, tx) }) + log(`Paid out ${payout} to user ${user.id}.`) + await createLoanIncomeNotification(user, payout) + return { payout } } const payUserLoan = async ( @@ -140,18 +94,18 @@ const payUserLoan = async ( .toISOString() // make sure we don't already have a txn for this user/questType - const { count } = await tx.one( - `select count(*) from txns - where to_id = $1 - and category = 'LOAN' - and created_time >= $2 - limit 1`, - [userId, startOfDay] - ) - - if (count) { - throw new APIError(400, 'Already awarded loan today') - } + // const { count } = await tx.one( + // `select count(*) from txns + // where to_id = $1 + // and category = 'LOAN' + // and created_time >= $2 + // limit 1`, + // [userId, startOfDay] + // ) + + // if (count) { + // throw new APIError(400, 'Already awarded loan today') + // } const loanTxn: Omit = { fromType: 'BANK', diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index cafbbc2174..bf1d55ee5c 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -136,6 +136,7 @@ import { generateAIMarketSuggestions } from './generate-ai-market-suggestions' import { generateAIMarketSuggestions2 } from './generate-ai-market-suggestions-2' import { generateAIDescription } from './generate-ai-description' import { generateAIAnswers } from './generate-ai-answers' +import { getNextLoanAmount } from './get-next-loan-amount' // we define the handlers in this object in order to typecheck that every API has a handler export const handlers: { [k in APIPath]: APIHandler } = { @@ -291,4 +292,5 @@ export const handlers: { [k in APIPath]: APIHandler } = { 'generate-ai-market-suggestions-2': generateAIMarketSuggestions2, 'generate-ai-description': generateAIDescription, 'generate-ai-answers': generateAIAnswers, + 'get-next-loan-amount': getNextLoanAmount, } diff --git a/backend/shared/src/update-user-portfolio-histories-core.ts b/backend/shared/src/update-user-portfolio-histories-core.ts index ce51f8060e..0eefdf72f7 100644 --- a/backend/shared/src/update-user-portfolio-histories-core.ts +++ b/backend/shared/src/update-user-portfolio-histories-core.ts @@ -3,14 +3,17 @@ import { SupabaseDirectClient, } from 'shared/supabase/init' import { contractColumnsToSelect, getUsers, isProd, log } from 'shared/utils' -import { groupBy, sumBy, uniq } from 'lodash' +import { Dictionary, groupBy, sumBy, uniq } from 'lodash' import { Contract, ContractToken, CPMMMultiContract, MarketContract, } from 'common/contract' -import { calculateProfitMetricsWithProb } from 'common/calculate-metrics' +import { + calculateProfitMetricsWithProb, + calculateUpdatedMetricsForContracts, +} from 'common/calculate-metrics' import { buildArray, filterDefined } from 'common/util/array' import { convertPortfolioHistory } from 'common/supabase/portfolio-metrics' @@ -21,6 +24,7 @@ import { BOT_USERNAMES } from 'common/envs/constants' import { bulkInsert } from 'shared/supabase/utils' import { type User } from 'common/user' import { convertAnswer, convertContract } from 'common/supabase/contracts' +import { Answer } from 'common/answer' const userToPortfolioMetrics: { [userId: string]: { @@ -207,7 +211,12 @@ export async function updateUserPortfolioHistoriesCore(userIds?: string[]) { export const getUnresolvedContractMetricsContractsAnswers = async ( pg: SupabaseDirectClient, userIds: string[] -) => { +): Promise<{ + metrics: RankedContractMetric[] + contracts: MarketContract[] + answers: Answer[] + metricsByContract: Dictionary[]> +}> => { const metrics = await pg.map( ` select ucm.data, coalesce((c.data->'isRanked')::boolean, true) as is_ranked @@ -228,21 +237,30 @@ export const getUnresolvedContractMetricsContractsAnswers = async ( metrics: [], contracts: [], answers: [], + metricsByContract: {}, } } + const contractIds = uniq(metrics.map((m) => m.contractId)) const answerIds = filterDefined(uniq(metrics.map((m) => m.answerId))) const selectContracts = `select ${contractColumnsToSelect} from contracts where id in ($1:list);` if (answerIds.length === 0) { - const contracts = await pg.map( + const contracts = await pg.map( selectContracts, [contractIds], convertContract ) + const contractsWithMetrics = contracts.map((c) => ({ + contract: c, + metrics: metrics.filter((m) => m.contractId === c.id), + })) + const { metricsByContract } = + calculateUpdatedMetricsForContracts(contractsWithMetrics) return { metrics, contracts, answers: [], + metricsByContract: metricsByContract, } } const results = await pg.multi( @@ -252,12 +270,19 @@ export const getUnresolvedContractMetricsContractsAnswers = async ( `, [contractIds, answerIds] ) - const contracts = results[0].map(convertContract) + const contracts = results[0].map(convertContract) as MarketContract[] const answers = results[1].map(convertAnswer) + const { metricsByContract } = calculateUpdatedMetricsForContracts( + contracts.map((c) => ({ + contract: c, + metrics: metrics.filter((m) => m.contractId === c.id), + })) + ) return { metrics, contracts, answers, + metricsByContract: metricsByContract, } } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 60de34b26b..a41774e4b8 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1882,6 +1882,14 @@ export const API = (_apiTypeCheck = { }) .strict(), }, + 'get-next-loan-amount': { + method: 'GET', + visibility: 'undocumented', + cache: DEFAULT_CACHE_STRATEGY, + authed: true, + returns: {} as { amount: number }, + props: z.object({}), + }, } as const) export type APIPath = keyof typeof API diff --git a/common/src/loans.ts b/common/src/loans.ts index 88c285ff37..a4389df3c9 100644 --- a/common/src/loans.ts +++ b/common/src/loans.ts @@ -1,14 +1,8 @@ -import { Dictionary, sumBy, minBy, groupBy } from 'lodash' -import { Bet } from './bet' -import { getProfitMetrics, getSimpleCpmmInvested } from './calculate' -import { - Contract, - CPMMContract, - CPMMMultiContract, - CPMMNumericContract, -} from './contract' +import { Dictionary, orderBy, sumBy, first } from 'lodash' +import { MarketContract } from './contract' +import { PortfolioMetrics } from './portfolio-metrics' +import { ContractMetric } from './contract-metric' import { filterDefined } from './util/array' -import { PortfolioMetrics } from 'common/portfolio-metrics' export const LOAN_DAILY_RATE = 0.04 @@ -18,10 +12,10 @@ const calculateNewLoan = (investedValue: number, loanTotal: number) => { } export const getUserLoanUpdates = ( - betsByContractId: { [contractId: string]: Bet[] }, - contractsById: { [contractId: string]: Contract } + metricsByContractId: Dictionary[]>, + contractsById: Dictionary ) => { - const updates = calculateLoanBetUpdates(betsByContractId, contractsById) + const updates = calculateLoanMetricUpdates(metricsByContractId, contractsById) return { updates, payout: sumBy(updates, (update) => update.newLoan) } } @@ -29,62 +23,63 @@ export const overLeveraged = (loanTotal: number, investmentValue: number) => loanTotal / investmentValue >= 8 export const isUserEligibleForLoan = ( - portfolio: (PortfolioMetrics & { userId: string }) | undefined + portfolio: PortfolioMetrics & { userId: string } ) => { - if (!portfolio) return true - const { investmentValue, loanTotal } = portfolio return investmentValue > 0 && !overLeveraged(loanTotal ?? 0, investmentValue) } -const calculateLoanBetUpdates = ( - betsByContractId: Dictionary, - contractsById: Dictionary +const calculateLoanMetricUpdates = ( + metricsByContractId: Dictionary[]>, + contractsById: Dictionary ) => { - const contracts = filterDefined( - Object.keys(betsByContractId).map((contractId) => contractsById[contractId]) - ).filter((c) => !c.isResolved) - - return contracts.flatMap((c) => { - const bets = betsByContractId[c.id] - if (c.mechanism === 'cpmm-1') { - return getCpmmContractLoanUpdate(c, bets) ?? [] - } else if (c.mechanism === 'cpmm-multi-1') { - const betsByAnswerId = groupBy(bets, (bet) => bet.answerId) - return filterDefined( - Object.entries(betsByAnswerId).map(([answerId, bets]) => { - const answer = c.answers.find((a) => a.id === answerId) - if (!answer) return undefined - if (answer.resolution) return undefined - return getCpmmContractLoanUpdate(c, bets) - }) - ) - } else { - // Unsupported contract / mechanism for loans. - return [] - } - }) + return filterDefined( + Object.entries(metricsByContractId).flatMap(([contractId, metrics]) => { + const c = contractsById[contractId] + if (!c || c.isResolved || c.token !== 'MANA') return undefined + if (!metrics) { + console.error(`No metrics found for contract ${contractId}`) + return undefined + } + if (c.mechanism === 'cpmm-multi-1') { + return metrics + .filter( + (m) => + m.answerId !== null && + !c.answers.find((a) => a.id === m.answerId)?.resolutionTime + ) + .map((m) => getCpmmContractLoanUpdate(c, [m])) + } else { + return getCpmmContractLoanUpdate(c, metrics) + } + }) + ) } +export const getLoanMetric = (metrics: Omit[]) => + first(orderBy(metrics, (m) => m.answerId)) + const getCpmmContractLoanUpdate = ( - contract: CPMMContract | CPMMMultiContract | CPMMNumericContract, - bets: Bet[] + contract: MarketContract, + metrics: Omit[] ) => { - const invested = getSimpleCpmmInvested(bets) - const { payout: currentValue } = getProfitMetrics(contract, bets) - const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const metric = first(metrics) + if (!metric) return undefined + + const invested = metric.invested + const currentValue = metric.payout + const loanAmount = metric.loan ?? 0 const loanBasis = Math.min(invested, currentValue) const newLoan = calculateNewLoan(loanBasis, loanAmount) - const oldestBet = minBy(bets, (bet) => bet.createdTime) - if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined + if (!isFinite(newLoan) || newLoan <= 0) return undefined - const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan + const loanTotal = loanAmount + newLoan return { - userId: oldestBet.userId, + userId: metric.userId, contractId: contract.id, - betId: oldestBet.id, + answerId: metric.answerId, newLoan, loanTotal, } diff --git a/common/src/supabase/contracts.ts b/common/src/supabase/contracts.ts index 5ce734cccd..27c058f9e0 100644 --- a/common/src/supabase/contracts.ts +++ b/common/src/supabase/contracts.ts @@ -127,7 +127,7 @@ export const convertAnswer = (row: Row<'answers'>): Answer => }, }) -export const convertContract = (c: { +export const convertContract = (c: { data: Json importance_score: number | null view_count?: number | null @@ -137,7 +137,7 @@ export const convertContract = (c: { token?: string }) => removeUndefinedProps({ - ...(c.data as Contract), + ...(c.data as T), // Only updated in supabase: importanceScore: c.importance_score, conversionScore: c.conversion_score, @@ -145,7 +145,7 @@ export const convertContract = (c: { viewCount: Number(c.view_count), dailyScore: c.daily_score, token: c.token, - } as Contract) + } as T) export const followContract = async ( db: SupabaseClient, diff --git a/web/components/home/daily-loan.tsx b/web/components/home/daily-loan.tsx index ab0d87d351..525eecb003 100644 --- a/web/components/home/daily-loan.tsx +++ b/web/components/home/daily-loan.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react' import { User } from 'common/user' -import { formatMoney } from 'common/util/format' import { LoansModal } from 'web/components/profile/loans-modal' import { api, requestLoan } from 'web/lib/api/api' import { toast } from 'react-hot-toast' @@ -11,7 +10,6 @@ import { useHasReceivedLoanToday } from 'web/hooks/use-has-received-loan' import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state' import { Tooltip } from 'web/components/widgets/tooltip' import { track } from 'web/lib/service/analytics' -import { DAY_MS } from 'common/util/time' import { Button } from 'web/components/buttons/button' import clsx from 'clsx' import { dailyStatsClass } from 'web/components/home/daily-stats' @@ -19,6 +17,7 @@ import { Row } from 'web/components/layout/row' import { GiOpenChest, GiTwoCoins } from 'react-icons/gi' import { Col } from 'web/components/layout/col' import { TRADE_TERM } from 'common/envs/constants' +import { useAPIGetter } from 'web/hooks/use-api-getter' dayjs.extend(utc) dayjs.extend(timezone) @@ -29,7 +28,7 @@ export function DailyLoan(props: { showChest?: boolean className?: string }) { - const { user, showChest, refreshPortfolio, className } = props + const { user, showChest = true, refreshPortfolio, className } = props const [showLoansModal, setShowLoansModal] = useState(false) const [loaning, setLoaning] = useState(false) @@ -39,8 +38,10 @@ export function DailyLoan(props: { ) const { receivedLoanToday: receivedTxnLoan, checkTxns } = useHasReceivedLoanToday(user) - const notEligibleForLoan = false //user.nextLoanCached < 1 - const receivedLoanToday = receivedTxnLoan || justReceivedLoan + const { data } = useAPIGetter('get-next-loan-amount', {}) + const notEligibleForLoan = (data?.amount ?? 0) < 1 + + const receivedLoanToday = false //receivedTxnLoan || justReceivedLoan const getLoan = async () => { if (receivedLoanToday || notEligibleForLoan) { @@ -48,7 +49,6 @@ export function DailyLoan(props: { return } setLoaning(true) - const id = toast.loading('Requesting loan...') const res = await requestLoan().catch((e) => { console.error(e) toast.error('Error requesting loan') @@ -56,10 +56,8 @@ export function DailyLoan(props: { }) if (res) { await checkTxns() - toast.success(`${formatMoney(res.payout)} loan collected!`) setJustReceivedLoan(true) } - toast.dismiss(id) if (!user.hasSeenLoanModal) setTimeout(() => setShowLoansModal(true), 1000) setLoaning(false) track('request loan', { @@ -77,10 +75,10 @@ export function DailyLoan(props: { api('me/update', { hasSeenLoanModal: true }) }, [showLoansModal]) - const createdRecently = user.createdTime > Date.now() - 2 * DAY_MS - if (createdRecently) { - return null - } + // const createdRecently = user.createdTime > Date.now() - 2 * DAY_MS + // if (createdRecently) { + // return null + // } if (showChest) { return ( + {user && } ) } diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 1364a89e2d..21eea5ca4f 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -13,7 +13,7 @@ export function LoansModal(props: { }) { const { isOpen, user, setOpen } = props const { receivedLoanToday } = useHasReceivedLoanToday(user) - const { latestPortfolio, isEligible } = useIsEligibleForLoans(user?.id) + const { latestPortfolio, isEligible } = useIsEligibleForLoans(user.id) return ( diff --git a/web/hooks/use-is-eligible-for-loans.ts b/web/hooks/use-is-eligible-for-loans.ts index a7ad07bd96..9f364a3f76 100644 --- a/web/hooks/use-is-eligible-for-loans.ts +++ b/web/hooks/use-is-eligible-for-loans.ts @@ -1,10 +1,9 @@ import { useCurrentPortfolio } from 'web/hooks/use-portfolio-history' import { isUserEligibleForLoan } from 'common/loans' -export const useIsEligibleForLoans = (userId: string | null | undefined) => { +export const useIsEligibleForLoans = (userId: string) => { const latestPortfolio = useCurrentPortfolio(userId) - const isEligible = isUserEligibleForLoan( - latestPortfolio && userId ? { ...latestPortfolio, userId } : undefined - ) + if (!latestPortfolio) return { latestPortfolio, isEligible: false } + const isEligible = isUserEligibleForLoan({ ...latestPortfolio, userId }) return { latestPortfolio, isEligible } } diff --git a/web/next-env.d.ts b/web/next-env.d.ts index a4a7b3f5cf..4f11a03dc6 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. From cc02785ccf63d1da9dbf6d0d727b994bd89c35f0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 13:46:33 -0800 Subject: [PATCH 02/21] Remove unused, add backfill --- ...s => recalculate-user-contract-metrics.ts} | 37 +++++++++++++++++++ common/src/loans.ts | 5 +-- 2 files changed, 38 insertions(+), 4 deletions(-) rename backend/scripts/{recalculate-user-contract-metrics-from-bets.ts => recalculate-user-contract-metrics.ts} (75%) diff --git a/backend/scripts/recalculate-user-contract-metrics-from-bets.ts b/backend/scripts/recalculate-user-contract-metrics.ts similarity index 75% rename from backend/scripts/recalculate-user-contract-metrics-from-bets.ts rename to backend/scripts/recalculate-user-contract-metrics.ts index 04ab4e6806..e2a92a89c1 100644 --- a/backend/scripts/recalculate-user-contract-metrics-from-bets.ts +++ b/backend/scripts/recalculate-user-contract-metrics.ts @@ -4,12 +4,18 @@ import { SupabaseDirectClient } from 'shared/supabase/init' import { updateUserMetricPeriods } from 'shared/update-user-metric-periods' import { updateUserMetricsWithBets } from 'shared/update-user-metrics-with-bets' import { updateUserPortfolioHistoriesCore } from 'shared/update-user-portfolio-histories-core' +import { log } from 'shared/utils' const chunkSize = 10 const FIX_PERIODS = false const UPDATE_PORTFOLIO_HISTORIES = true +const MIGRATE_LOAN_DATA = true if (require.main === module) { runScript(async ({ pg }) => { + if (MIGRATE_LOAN_DATA) { + await migrateLoanData(pg) + return + } if (FIX_PERIODS) { await fixUserPeriods(pg) return @@ -72,3 +78,34 @@ const fixUserPeriods = async (pg: SupabaseDirectClient) => { console.log('last created time:', userIds[userIds.length - 1][1]) } } + +// Migrate loan data from data jsonb to native column +export async function migrateLoanData( + pg: SupabaseDirectClient, + chunkSize = 100 +) { + log('Getting all users with contract metrics...') + const userIds = await pg.map( + `select distinct user_id from user_contract_metrics`, + [], + (r) => r.user_id as string + ) + + log(`Found ${userIds.length} users with metrics`) + const chunks = chunk(userIds, chunkSize) + + for (const userChunk of chunks) { + await pg.none( + ` + update user_contract_metrics + set loan = coalesce((data->>'loan')::numeric, 0) + where user_id = any($1) + `, + [userChunk] + ) + + log(`Updated loan data for ${userChunk.length} users`) + } + + log('Finished migrating loan data') +} diff --git a/common/src/loans.ts b/common/src/loans.ts index a4389df3c9..41bd720ba0 100644 --- a/common/src/loans.ts +++ b/common/src/loans.ts @@ -1,4 +1,4 @@ -import { Dictionary, orderBy, sumBy, first } from 'lodash' +import { Dictionary, sumBy, first } from 'lodash' import { MarketContract } from './contract' import { PortfolioMetrics } from './portfolio-metrics' import { ContractMetric } from './contract-metric' @@ -56,9 +56,6 @@ const calculateLoanMetricUpdates = ( ) } -export const getLoanMetric = (metrics: Omit[]) => - first(orderBy(metrics, (m) => m.answerId)) - const getCpmmContractLoanUpdate = ( contract: MarketContract, metrics: Omit[] From 1123d41adde65c4d34b7a1af49fc10b2f0872bdc Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 14:06:53 -0800 Subject: [PATCH 03/21] Share next loan code --- backend/api/src/get-next-loan-amount.ts | 50 ++---------- backend/api/src/request-loan.ts | 104 +++++++++++++----------- backend/api/src/routes.ts | 4 +- 3 files changed, 64 insertions(+), 94 deletions(-) diff --git a/backend/api/src/get-next-loan-amount.ts b/backend/api/src/get-next-loan-amount.ts index c1c5b1d128..ac0906675f 100644 --- a/backend/api/src/get-next-loan-amount.ts +++ b/backend/api/src/get-next-loan-amount.ts @@ -1,52 +1,14 @@ -import { APIError, type APIHandler } from './helpers/endpoint' -import { createSupabaseDirectClient } from 'shared/supabase/init' -import { getUser, log } from 'shared/utils' -import { PortfolioMetrics } from 'common/portfolio-metrics' -import { getUserLoanUpdates, isUserEligibleForLoan } from 'common/loans' -import { getUnresolvedContractMetricsContractsAnswers } from 'shared/update-user-portfolio-histories-core' -import { keyBy } from 'lodash' +import { type APIHandler } from './helpers/endpoint' +import { getNextLoanAmountResults } from 'api/request-loan' export const getNextLoanAmount: APIHandler<'get-next-loan-amount'> = async ( _, auth ) => { - const pg = createSupabaseDirectClient() - - const portfolioMetric = await pg.oneOrNone( - `select user_id, ts, investment_value, balance, total_deposits - from user_portfolio_history_latest - where user_id = $1`, - [auth.uid], - (r) => - ({ - userId: r.user_id as string, - timestamp: Date.parse(r.ts as string), - investmentValue: parseFloat(r.investment_value as string), - balance: parseFloat(r.balance as string), - totalDeposits: parseFloat(r.total_deposits as string), - } as PortfolioMetrics & { userId: string }) - ) - if (!portfolioMetric) { + try { + const { result } = await getNextLoanAmountResults(auth.uid) + return { amount: result.payout } + } catch (e) { return { amount: 0 } } - log(`Loaded portfolio.`) - - if (!isUserEligibleForLoan(portfolioMetric)) { - return { amount: 0 } - } - - const user = await getUser(auth.uid) - if (!user) { - throw new APIError(404, `User ${auth.uid} not found`) - } - log(`Loaded user ${user.id}`) - - const { contracts, metricsByContract } = - await getUnresolvedContractMetricsContractsAnswers(pg, [user.id]) - log(`Loaded ${contracts.length} contracts.`) - - const contractsById = keyBy(contracts, 'id') - - const result = getUserLoanUpdates(metricsByContract, contractsById) - return { amount: result.payout } } diff --git a/backend/api/src/request-loan.ts b/backend/api/src/request-loan.ts index bbd45b71a8..875eeee29d 100644 --- a/backend/api/src/request-loan.ts +++ b/backend/api/src/request-loan.ts @@ -19,45 +19,11 @@ import { filterDefined } from 'common/util/array' import { getUnresolvedContractMetricsContractsAnswers } from 'shared/update-user-portfolio-histories-core' import { keyBy } from 'lodash' -export const requestloan: APIHandler<'request-loan'> = async (_, auth) => { +export const requestLoan: APIHandler<'request-loan'> = async (_, auth) => { const pg = createSupabaseDirectClient() - - const portfolioMetric = await pg.oneOrNone( - `select user_id, ts, investment_value, balance, total_deposits - from user_portfolio_history_latest - where user_id = $1`, - [auth.uid], - (r) => - ({ - userId: r.user_id as string, - timestamp: Date.parse(r.ts as string), - investmentValue: parseFloat(r.investment_value as string), - balance: parseFloat(r.balance as string), - totalDeposits: parseFloat(r.total_deposits as string), - } as PortfolioMetrics & { userId: string }) + const { result, metricsByContract, user } = await getNextLoanAmountResults( + auth.uid ) - if (!portfolioMetric) { - throw new APIError(404, `No portfolio found for user ${auth.uid}`) - } - log(`Loaded portfolio.`) - - if (!isUserEligibleForLoan(portfolioMetric)) { - throw new APIError(400, `User ${auth.uid} is not eligible for a loan`) - } - - const user = await getUser(auth.uid) - if (!user) { - throw new APIError(404, `User ${auth.uid} not found`) - } - log(`Loaded user ${user.id}`) - - const { contracts, metricsByContract } = - await getUnresolvedContractMetricsContractsAnswers(pg, [user.id]) - log(`Loaded ${contracts.length} contracts.`) - - const contractsById = keyBy(contracts, 'id') - - const result = getUserLoanUpdates(metricsByContract, contractsById) const { updates, payout } = result if (payout < 1) { throw new APIError(400, `User ${auth.uid} is not eligible for a loan`) @@ -94,18 +60,18 @@ const payUserLoan = async ( .toISOString() // make sure we don't already have a txn for this user/questType - // const { count } = await tx.one( - // `select count(*) from txns - // where to_id = $1 - // and category = 'LOAN' - // and created_time >= $2 - // limit 1`, - // [userId, startOfDay] - // ) + const { count } = await tx.one( + `select count(*) from txns + where to_id = $1 + and category = 'LOAN' + and created_time >= $2 + limit 1`, + [userId, startOfDay] + ) - // if (count) { - // throw new APIError(400, 'Already awarded loan today') - // } + if (count) { + throw new APIError(400, 'Already awarded loan today') + } const loanTxn: Omit = { fromType: 'BANK', @@ -121,3 +87,45 @@ const payUserLoan = async ( } await runTxnFromBank(tx, loanTxn, true) } + +export const getNextLoanAmountResults = async (userId: string) => { + const pg = createSupabaseDirectClient() + + const portfolioMetric = await pg.oneOrNone( + `select user_id, ts, investment_value, balance, total_deposits + from user_portfolio_history_latest + where user_id = $1`, + [userId], + (r) => + ({ + userId: r.user_id as string, + timestamp: Date.parse(r.ts as string), + investmentValue: parseFloat(r.investment_value as string), + balance: parseFloat(r.balance as string), + totalDeposits: parseFloat(r.total_deposits as string), + } as PortfolioMetrics & { userId: string }) + ) + if (!portfolioMetric) { + throw new APIError(404, `No portfolio found for user ${userId}`) + } + log(`Loaded portfolio.`) + + if (!isUserEligibleForLoan(portfolioMetric)) { + throw new APIError(400, `User ${userId} is not eligible for a loan`) + } + + const user = await getUser(userId) + if (!user) { + throw new APIError(404, `User ${userId} not found`) + } + log(`Loaded user ${user.id}`) + + const { contracts, metricsByContract } = + await getUnresolvedContractMetricsContractsAnswers(pg, [user.id]) + log(`Loaded ${contracts.length} contracts.`) + + const contractsById = keyBy(contracts, 'id') + + const result = getUserLoanUpdates(metricsByContract, contractsById) + return { result, user, metricsByContract, contracts } +} diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index bf1d55ee5c..a4aa975d53 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -44,7 +44,7 @@ import { searchMarketsLite, searchMarketsFull } from './search-contracts' import { post } from 'api/post' import { fetchLinkPreview } from './fetch-link-preview' import { type APIHandler } from './helpers/endpoint' -import { requestloan } from 'api/request-loan' +import { requestLoan } from 'api/request-loan' import { removePinnedPhoto } from './love/remove-pinned-photo' import { getHeadlines, getPoliticsHeadlines } from './get-headlines' import { getadanalytics } from 'api/get-ad-analytics' @@ -226,7 +226,7 @@ export const handlers: { [k in APIPath]: APIHandler } = { 'compatible-lovers': getCompatibleLovers, post: post, 'fetch-link-preview': fetchLinkPreview, - 'request-loan': requestloan, + 'request-loan': requestLoan, 'remove-pinned-photo': removePinnedPhoto, 'get-related-markets': getRelatedMarkets, 'get-related-markets-by-group': getRelatedMarketsByGroup, From 524b9684629a11045c0464442d5a2c1b3c7d617c Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 14:08:28 -0800 Subject: [PATCH 04/21] Modify return --- backend/api/src/request-loan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/src/request-loan.ts b/backend/api/src/request-loan.ts index 875eeee29d..3cd3d8cfe5 100644 --- a/backend/api/src/request-loan.ts +++ b/backend/api/src/request-loan.ts @@ -127,5 +127,5 @@ export const getNextLoanAmountResults = async (userId: string) => { const contractsById = keyBy(contracts, 'id') const result = getUserLoanUpdates(metricsByContract, contractsById) - return { result, user, metricsByContract, contracts } + return { result, user, metricsByContract } } From 321c49f6b329c2f8a2f5c58ddfd3f25e797f0693 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 14:09:35 -0800 Subject: [PATCH 05/21] Add back in safeguards --- web/components/home/daily-loan.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/components/home/daily-loan.tsx b/web/components/home/daily-loan.tsx index 525eecb003..5452142f2c 100644 --- a/web/components/home/daily-loan.tsx +++ b/web/components/home/daily-loan.tsx @@ -18,6 +18,7 @@ import { GiOpenChest, GiTwoCoins } from 'react-icons/gi' import { Col } from 'web/components/layout/col' import { TRADE_TERM } from 'common/envs/constants' import { useAPIGetter } from 'web/hooks/use-api-getter' +import { DAY_MS } from 'common/util/time' dayjs.extend(utc) dayjs.extend(timezone) @@ -41,7 +42,7 @@ export function DailyLoan(props: { const { data } = useAPIGetter('get-next-loan-amount', {}) const notEligibleForLoan = (data?.amount ?? 0) < 1 - const receivedLoanToday = false //receivedTxnLoan || justReceivedLoan + const receivedLoanToday = receivedTxnLoan || justReceivedLoan const getLoan = async () => { if (receivedLoanToday || notEligibleForLoan) { @@ -75,10 +76,10 @@ export function DailyLoan(props: { api('me/update', { hasSeenLoanModal: true }) }, [showLoansModal]) - // const createdRecently = user.createdTime > Date.now() - 2 * DAY_MS - // if (createdRecently) { - // return null - // } + const createdRecently = user.createdTime > Date.now() - 2 * DAY_MS + if (createdRecently) { + return null + } if (showChest) { return ( Date: Wed, 4 Dec 2024 14:13:38 -0800 Subject: [PATCH 06/21] Fix summary trigger --- backend/scripts/recalculate-user-contract-metrics.ts | 2 +- backend/supabase/user_contract_metrics.sql | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/scripts/recalculate-user-contract-metrics.ts b/backend/scripts/recalculate-user-contract-metrics.ts index e2a92a89c1..97142e890e 100644 --- a/backend/scripts/recalculate-user-contract-metrics.ts +++ b/backend/scripts/recalculate-user-contract-metrics.ts @@ -82,7 +82,7 @@ const fixUserPeriods = async (pg: SupabaseDirectClient) => { // Migrate loan data from data jsonb to native column export async function migrateLoanData( pg: SupabaseDirectClient, - chunkSize = 100 + chunkSize = 200 ) { log('Getting all users with contract metrics...') const userIds = await pg.map( diff --git a/backend/supabase/user_contract_metrics.sql b/backend/supabase/user_contract_metrics.sql index dacbdc3541..32a0b94d35 100644 --- a/backend/supabase/user_contract_metrics.sql +++ b/backend/supabase/user_contract_metrics.sql @@ -51,7 +51,12 @@ BEGIN -- Update the row where answer_id is null with the aggregated metrics UPDATE user_contract_metrics SET - data = data || jsonb_build_object('hasYesShares', sum_has_yes_shares, 'hasNoShares', sum_has_no_shares, 'hasShares', sum_has_shares), + data = data || jsonb_build_object( + 'hasYesShares', sum_has_yes_shares, + 'hasNoShares', sum_has_no_shares, + 'hasShares', sum_has_shares, + 'loan', sum_loan + ), has_yes_shares = sum_has_yes_shares, has_no_shares = sum_has_no_shares, has_shares = sum_has_shares, From d06f08ceb75a32afbdd792b44fc4d519a449278f Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 14:15:01 -0800 Subject: [PATCH 07/21] Table def --- backend/supabase/user_contract_metrics.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/supabase/user_contract_metrics.sql b/backend/supabase/user_contract_metrics.sql index 32a0b94d35..c230b5be35 100644 --- a/backend/supabase/user_contract_metrics.sql +++ b/backend/supabase/user_contract_metrics.sql @@ -12,7 +12,7 @@ create table if not exists total_shares_no numeric, total_shares_yes numeric, user_id text not null, - loan numeric + loan numeric default 0 ); -- Triggers From 0e289a15d795905504d652f7bb4300baaeccb349 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 14:52:43 -0800 Subject: [PATCH 08/21] Fix cache & loan repayment in place-bet --- backend/api/src/get-next-loan-amount.ts | 9 ++++----- backend/api/src/place-bet.ts | 2 +- common/src/api/schema.ts | 6 ++++-- web/components/home/daily-loan.tsx | 3 ++- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/api/src/get-next-loan-amount.ts b/backend/api/src/get-next-loan-amount.ts index ac0906675f..dddfb4613a 100644 --- a/backend/api/src/get-next-loan-amount.ts +++ b/backend/api/src/get-next-loan-amount.ts @@ -1,12 +1,11 @@ import { type APIHandler } from './helpers/endpoint' import { getNextLoanAmountResults } from 'api/request-loan' -export const getNextLoanAmount: APIHandler<'get-next-loan-amount'> = async ( - _, - auth -) => { +export const getNextLoanAmount: APIHandler<'get-next-loan-amount'> = async ({ + userId, +}) => { try { - const { result } = await getNextLoanAmountResults(auth.uid) + const { result } = await getNextLoanAmountResults(userId) return { amount: result.payout } } catch (e) { return { amount: 0 } diff --git a/backend/api/src/place-bet.ts b/backend/api/src/place-bet.ts index 8b599caee7..bab624cb0d 100644 --- a/backend/api/src/place-bet.ts +++ b/backend/api/src/place-bet.ts @@ -413,7 +413,7 @@ export const executeNewBetResult = async ( { id: user.id, [contract.token === 'CASH' ? 'cashBalance' : 'balance']: - -newBet.amount - apiFee, + -newBet.amount - apiFee + (newBet.loanAmount ?? 0), }, ] const makersByTakerBetId: Record = { diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index a41774e4b8..500d2ad72b 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1886,9 +1886,11 @@ export const API = (_apiTypeCheck = { method: 'GET', visibility: 'undocumented', cache: DEFAULT_CACHE_STRATEGY, - authed: true, + authed: false, returns: {} as { amount: number }, - props: z.object({}), + props: z.object({ + userId: z.string(), + }), }, } as const) diff --git a/web/components/home/daily-loan.tsx b/web/components/home/daily-loan.tsx index 5452142f2c..1aace2a5c9 100644 --- a/web/components/home/daily-loan.tsx +++ b/web/components/home/daily-loan.tsx @@ -39,7 +39,8 @@ export function DailyLoan(props: { ) const { receivedLoanToday: receivedTxnLoan, checkTxns } = useHasReceivedLoanToday(user) - const { data } = useAPIGetter('get-next-loan-amount', {}) + const { data } = useAPIGetter('get-next-loan-amount', { userId: user.id }) + console.log('data', data) const notEligibleForLoan = (data?.amount ?? 0) < 1 const receivedLoanToday = receivedTxnLoan || justReceivedLoan From 64b13c6c787bbf31de2ada9da2dec6e299850439 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 4 Dec 2024 14:53:10 -0800 Subject: [PATCH 09/21] Remove log --- web/components/home/daily-loan.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/home/daily-loan.tsx b/web/components/home/daily-loan.tsx index 1aace2a5c9..45f4a77484 100644 --- a/web/components/home/daily-loan.tsx +++ b/web/components/home/daily-loan.tsx @@ -40,7 +40,6 @@ export function DailyLoan(props: { const { receivedLoanToday: receivedTxnLoan, checkTxns } = useHasReceivedLoanToday(user) const { data } = useAPIGetter('get-next-loan-amount', { userId: user.id }) - console.log('data', data) const notEligibleForLoan = (data?.amount ?? 0) < 1 const receivedLoanToday = receivedTxnLoan || justReceivedLoan From 510aefa8e40471780671a4426984f50b0a6cba70 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 14:50:08 -0800 Subject: [PATCH 10/21] Calculate payouts with contract metrics --- backend/api/src/request-loan.ts | 13 +- backend/api/src/unresolve.ts | 18 +- backend/shared/src/create-notification.ts | 8 +- .../src/helpers/user-contract-metrics.ts | 3 + backend/shared/src/resolve-market-helpers.ts | 113 ++++++----- backend/shared/src/txn/run-txn.ts | 6 +- .../update-user-portfolio-histories-core.ts | 6 +- backend/shared/src/utils.ts | 24 ++- backend/shared/src/websockets/helpers.ts | 2 +- common/src/calculate-metrics.ts | 109 +++++----- common/src/loans.ts | 4 +- common/src/payouts-fixed.ts | 186 ++++++++++-------- common/src/payouts.ts | 50 ++--- common/src/portfolio-metrics.ts | 1 + common/src/supabase/portfolio-metrics.ts | 1 + web/hooks/use-saved-contract-metrics.ts | 6 +- 16 files changed, 308 insertions(+), 242 deletions(-) diff --git a/backend/api/src/request-loan.ts b/backend/api/src/request-loan.ts index 3cd3d8cfe5..bf997234a0 100644 --- a/backend/api/src/request-loan.ts +++ b/backend/api/src/request-loan.ts @@ -5,7 +5,6 @@ import { } from 'shared/supabase/init' import { createLoanIncomeNotification } from 'shared/create-notification' import { getUser, log } from 'shared/utils' -import { PortfolioMetrics } from 'common/portfolio-metrics' import { getUserLoanUpdates, isUserEligibleForLoan } from 'common/loans' import * as dayjs from 'dayjs' import * as utc from 'dayjs/plugin/utc' @@ -18,6 +17,7 @@ import { runTxnFromBank } from 'shared/txn/run-txn' import { filterDefined } from 'common/util/array' import { getUnresolvedContractMetricsContractsAnswers } from 'shared/update-user-portfolio-histories-core' import { keyBy } from 'lodash' +import { convertPortfolioHistory } from 'common/supabase/portfolio-metrics' export const requestLoan: APIHandler<'request-loan'> = async (_, auth) => { const pg = createSupabaseDirectClient() @@ -92,18 +92,11 @@ export const getNextLoanAmountResults = async (userId: string) => { const pg = createSupabaseDirectClient() const portfolioMetric = await pg.oneOrNone( - `select user_id, ts, investment_value, balance, total_deposits + `select * from user_portfolio_history_latest where user_id = $1`, [userId], - (r) => - ({ - userId: r.user_id as string, - timestamp: Date.parse(r.ts as string), - investmentValue: parseFloat(r.investment_value as string), - balance: parseFloat(r.balance as string), - totalDeposits: parseFloat(r.total_deposits as string), - } as PortfolioMetrics & { userId: string }) + convertPortfolioHistory ) if (!portfolioMetric) { throw new APIError(404, `No portfolio found for user ${userId}`) diff --git a/backend/api/src/unresolve.ts b/backend/api/src/unresolve.ts index 9ee0952937..c39688bf83 100644 --- a/backend/api/src/unresolve.ts +++ b/backend/api/src/unresolve.ts @@ -295,21 +295,21 @@ const undoResolution = async ( } else if (answerId) { const answer = await pg.one( ` - with last_bet as ( - select prob_after from contract_bets - where answer_id = $1 - and contract_id = $2 - order by created_time desc - limit 1 - ) update answers set resolution = null, resolution_time = null, resolution_probability = null, - prob = coalesce(last_bet.prob_after,0.5), + prob = coalesce( + (select prob_after + from contract_bets + where answer_id = $1 + and contract_id = $2 + order by created_time desc + limit 1), + 0.5 + ), resolver_id = null - from last_bet where id = $1 returning *`, [answerId, contractId], diff --git a/backend/shared/src/create-notification.ts b/backend/shared/src/create-notification.ts index 78294e3993..d49224320d 100644 --- a/backend/shared/src/create-notification.ts +++ b/backend/shared/src/create-notification.ts @@ -1,4 +1,3 @@ -import { getContractBetMetrics } from 'common/calculate' import { BetFillData, BetReplyNotificationData, @@ -91,6 +90,7 @@ import { answerToMidpoint, } from 'common/multi-numeric' import { floatingEqual } from 'common/util/math' +import { ContractMetric } from 'common/contract-metric' type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } @@ -1213,8 +1213,8 @@ export const createContractResolvedNotifications = async ( resolutionValue: number | undefined, answerId: string | undefined, resolutionData: { - userIdToContractMetrics: { - [userId: string]: ReturnType + userIdToContractMetric: { + [userId: string]: Omit } userPayouts: { [userId: string]: number } creatorPayout: number @@ -1261,7 +1261,7 @@ export const createContractResolvedNotifications = async ( const bulkPushNotifications: [PrivateUser, Notification, string, string][] = [] const { - userIdToContractMetrics, + userIdToContractMetric: userIdToContractMetrics, userPayouts, creatorPayout, resolutionProbability, diff --git a/backend/shared/src/helpers/user-contract-metrics.ts b/backend/shared/src/helpers/user-contract-metrics.ts index 1f3d556d86..a53708f075 100644 --- a/backend/shared/src/helpers/user-contract-metrics.ts +++ b/backend/shared/src/helpers/user-contract-metrics.ts @@ -64,6 +64,9 @@ export async function bulkUpdateContractMetrics( export function bulkUpdateContractMetricsQuery( metrics: Omit[] ) { + if (metrics.length === 0) { + return 'select 1 where false' + } return bulkUpsertQuery( 'user_contract_metrics', [], diff --git a/backend/shared/src/resolve-market-helpers.ts b/backend/shared/src/resolve-market-helpers.ts index dba2623477..2700798942 100644 --- a/backend/shared/src/resolve-market-helpers.ts +++ b/backend/shared/src/resolve-market-helpers.ts @@ -1,10 +1,8 @@ -import { mapValues, groupBy, sum, sumBy } from 'lodash' +import { mapValues, groupBy, sum, sumBy, keyBy } from 'lodash' import { HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' -import { Bet } from 'common/bet' -import { getContractBetMetrics } from 'common/calculate' import { Contract, contractPath, @@ -16,17 +14,17 @@ import { Txn, CancelUniqueBettorBonusTxn } from 'common/txn' import { User } from 'common/user' import { removeUndefinedProps } from 'common/util/object' import { createContractResolvedNotifications } from './create-notification' -import { updateContractMetricsForUsers } from './helpers/user-contract-metrics' +import { bulkUpdateContractMetricsQuery } from './helpers/user-contract-metrics' import { TxnData, - runTxnInBetQueueIgnoringBalance, + runTxnOutsideBetQueueIgnoringBalance, txnToRow, } from './txn/run-txn' import { revalidateStaticProps, isProd, log, - getContractAndBetsAndLiquidities, + getContractAndMetricsAndLiquidities, } from './utils' import { getLoanPayouts, getPayouts, groupPayoutsByUser } from 'common/payouts' import { APIError } from 'common//api/utils' @@ -42,7 +40,12 @@ import { convertTxn } from 'common/supabase/txns' import { updateAnswer, updateAnswers } from './supabase/answers' import { bulkInsertQuery, updateDataQuery } from './supabase/utils' import { bulkIncrementBalancesQuery, UserUpdate } from './supabase/users' -import { broadcastUpdatedContract } from './websockets/helpers' +import { + broadcastUpdatedContract, + broadcastUpdatedMetrics, +} from './websockets/helpers' +import { ContractMetric } from 'common/contract-metric' +import { calculateUpdatedMetricsForContracts } from 'common/calculate-metrics' export type ResolutionParams = { outcome: string @@ -61,8 +64,8 @@ export const resolveMarketHelper = async ( const pg = createSupabaseDirectClient() const { - contract, - bets, + resolvedContract, + updatedContractMetrics, payoutsWithoutLoans, updatedContractAttrs, userUpdates, @@ -70,12 +73,14 @@ export const resolveMarketHelper = async ( const { closeTime, id: contractId, outcomeType } = unresolvedContract const { contract: c, - bets, liquidities, - } = await getContractAndBetsAndLiquidities(tx, unresolvedContract, answerId) - if (!c) { - throw new APIError(500, 'Contract not found') - } + contractMetrics, + } = await getContractAndMetricsAndLiquidities( + tx, + unresolvedContract, + answerId + ) + unresolvedContract = c as MarketContract if (unresolvedContract.isResolved) { throw new APIError(403, 'Contract is already resolved') @@ -86,7 +91,6 @@ export const resolveMarketHelper = async ( ? Math.min(closeTime, resolutionTime) : closeTime - // ian: TODO: just use contract metrics for this (but after the election) const { resolutionProbability, payouts, payoutsWithoutLoans } = getPayoutInfo( outcome, @@ -94,7 +98,7 @@ export const resolveMarketHelper = async ( resolutions, probabilityInt, answerId, - bets, + contractMetrics, liquidities ) // Keep MKT resolution prob for consistency's sake @@ -184,19 +188,20 @@ export const resolveMarketHelper = async ( answers: unresolvedContract.answers.map((a) => ({ ...a, ...updateAnswerAttrs, - prob: resolutions ? (resolutions[a.id] ?? 0) / 100 : undefined, + prob: resolutions ? (resolutions[a.id] ?? 0) / 100 : a.prob, resolutionProbability: a.prob, })), } as Partial & { id: string } } - const contract = { + const resolvedContract = { ...unresolvedContract, ...updatedContractAttrs, } as Contract // handle exploit where users can get negative payouts - const negPayoutThreshold = contract.uniqueBettorCount < 100 ? 0 : -1000 + const negPayoutThreshold = + resolvedContract.uniqueBettorCount < 100 ? 0 : -1000 const userPayouts = groupPayoutsByUser(payouts) log('user payouts', { userPayouts }) @@ -222,8 +227,11 @@ export const resolveMarketHelper = async ( if (updateAnswerAttrs && answerId) { const props = removeUndefinedProps(updateAnswerAttrs) await updateAnswer(tx, answerId, props) - } else if (updateAnswerAttrs && contract.mechanism === 'cpmm-multi-1') { - const answerUpdates = contract.answers.map((a) => + } else if ( + updateAnswerAttrs && + resolvedContract.mechanism === 'cpmm-multi-1' + ) { + const answerUpdates = resolvedContract.answers.map((a) => removeUndefinedProps({ id: a.id, ...updateAnswerAttrs, @@ -239,7 +247,7 @@ export const resolveMarketHelper = async ( contractId, answerId, { - payoutCash: contract.token === 'CASH', + payoutCash: resolvedContract.token === 'CASH', } ) @@ -249,37 +257,43 @@ export const resolveMarketHelper = async ( 'id', updatedContractAttrs ) + const { metricsByContract } = calculateUpdatedMetricsForContracts([ + { contract: resolvedContract, metrics: contractMetrics }, + ]) + const updatedContractMetrics = metricsByContract[resolvedContract.id] ?? [] + const updateMetricsQuery = bulkUpdateContractMetricsQuery( + updatedContractMetrics + ) const results = await tx.multi(` ${balanceUpdatesQuery}; -- 1 ${insertTxnsQuery}; -- 2 ${contractUpdateQuery}; -- 3 + ${updateMetricsQuery}; -- 4 `) const userUpdates = results[0] as UserUpdate[] // TODO: we may want to support clawing back trader bonuses on MC markets too if (!answerId && outcome === 'CANCEL') { - await undoUniqueBettorRewardsIfCancelResolution(tx, contract) + await undoUniqueBettorRewardsIfCancelResolution(tx, resolvedContract) } + return { - contract, - bets, + resolvedContract, payoutsWithoutLoans, updatedContractAttrs, userUpdates, + updatedContractMetrics, } }) - broadcastUpdatedContract(contract.visibility, updatedContractAttrs) + broadcastUpdatedContract(resolvedContract.visibility, updatedContractAttrs) + broadcastUpdatedMetrics(updatedContractMetrics) const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans) - const userIdToContractMetrics = mapValues( - groupBy(bets, (bet) => bet.userId), - (bets) => getContractBetMetrics(contract, bets) - ) await trackPublicEvent(resolver.id, 'resolve market', { resolution: outcome, - contractId: contract.id, + contractId: resolvedContract.id, }) await recordContractEdit( @@ -288,11 +302,15 @@ export const resolveMarketHelper = async ( Object.keys(updatedContractAttrs ?? {}) ) - await updateContractMetricsForUsers(contract, bets) - await revalidateStaticProps(contractPath(contract)) - + await revalidateStaticProps(contractPath(resolvedContract)) + const userIdToContractMetric = keyBy( + updatedContractMetrics.filter((m) => + answerId ? m.answerId === answerId : m.answerId == null + ), + 'userId' + ) await createContractResolvedNotifications( - contract, + resolvedContract, resolver, creator, outcome, @@ -300,15 +318,15 @@ export const resolveMarketHelper = async ( value, answerId, { - userIdToContractMetrics, + userIdToContractMetric, userPayouts: userPayoutsWithoutLoans, creatorPayout: 0, - resolutionProbability: contract.resolutionProbability, + resolutionProbability: resolvedContract.resolutionProbability, resolutions, } ) - return { contract, userUpdates } + return { contract: resolvedContract, userUpdates } } export const getPayoutInfo = ( @@ -317,7 +335,7 @@ export const getPayoutInfo = ( resolutions: { [key: string]: number } | undefined, probabilityInt: number | undefined, answerId: string | undefined, - bets: Bet[], + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[] ) => { const resolutionProbability = @@ -329,21 +347,26 @@ export const getPayoutInfo = ( return mapValues(resolutions, (p) => p / total) })() : undefined - const loanPayouts = getLoanPayouts(bets) - const { payouts: traderPayouts, liquidityPayouts } = getPayouts( + // Calculate loan payouts from contract metrics + const loanPayouts = getLoanPayouts(contractMetrics) + + // Calculate payouts using contract metrics instead of bets + const { traderPayouts, liquidityPayouts } = getPayouts( outcome, unresolvedContract, - bets, + contractMetrics, liquidities, resolutionProbs, resolutionProbability, answerId ) + const payoutsWithoutLoans = [ ...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })), ...traderPayouts, ] + if (!isProd()) console.log( 'trader payouts:', @@ -353,12 +376,14 @@ export const getPayoutInfo = ( 'loan payouts:', loanPayouts ) + const payouts = [...payoutsWithoutLoans, ...loanPayouts].filter( (p) => p.payout !== 0 ) + return { payoutsWithoutLoans, - bets, + contractMetrics, resolutionProbs, resolutionProbability, payouts, @@ -401,7 +426,7 @@ async function undoUniqueBettorRewardsIfCancelResolution( }, } as Omit - const txn = await runTxnInBetQueueIgnoringBalance(pg, undoBonusTxn) + const txn = await runTxnOutsideBetQueueIgnoringBalance(pg, undoBonusTxn) log(`Cancel Bonus txn for user: ${contract.creatorId} completed: ${txn.id}`) } diff --git a/backend/shared/src/txn/run-txn.ts b/backend/shared/src/txn/run-txn.ts index cde15a727c..dd7947a446 100644 --- a/backend/shared/src/txn/run-txn.ts +++ b/backend/shared/src/txn/run-txn.ts @@ -29,12 +29,12 @@ export async function runTxnInBetQueue( } // Could also be named: confiscateFunds -export async function runTxnInBetQueueIgnoringBalance( +export async function runTxnOutsideBetQueueIgnoringBalance( pgTransaction: SupabaseTransaction, data: TxnData, affectsProfit = false ) { - return await runTxnInternal(pgTransaction, data, affectsProfit, true, false) + return await runTxnInternal(pgTransaction, data, affectsProfit, false, false) } async function runTxnInternal( @@ -44,7 +44,7 @@ async function runTxnInternal( useQueue = true, checkBalance = true ) { - const { amount, fromType, fromId, toId, toType, token, category } = data + const { amount, fromType, fromId, toId, toType, token } = data const deps = buildArray( (fromType === 'USER' || fromType === 'CONTRACT') && fromId, (toType === 'USER' || toType === 'CONTRACT') && toId diff --git a/backend/shared/src/update-user-portfolio-histories-core.ts b/backend/shared/src/update-user-portfolio-histories-core.ts index 0eefdf72f7..1152ff7f29 100644 --- a/backend/shared/src/update-user-portfolio-histories-core.ts +++ b/backend/shared/src/update-user-portfolio-histories-core.ts @@ -11,7 +11,7 @@ import { MarketContract, } from 'common/contract' import { - calculateProfitMetricsWithProb, + calculateProfitMetricsAtProbOrCancel, calculateUpdatedMetricsForContracts, } from 'common/calculate-metrics' import { buildArray, filterDefined } from 'common/util/array' @@ -332,7 +332,7 @@ export const getUnresolvedStatsForToken = ( } return { value: - calculateProfitMetricsWithProb(answer.prob, cm).payout - + calculateProfitMetricsAtProbOrCancel(answer.prob, cm).payout - (cm.loan ?? 0), invested: cm.invested ?? 0, dailyProfit: cm.from?.day?.profit ?? 0, @@ -341,7 +341,7 @@ export const getUnresolvedStatsForToken = ( return { value: - calculateProfitMetricsWithProb(contract.prob, cm).payout - + calculateProfitMetricsAtProbOrCancel(contract.prob, cm).payout - (cm.loan ?? 0), invested: cm.invested ?? 0, dailyProfit: cm.from?.day?.profit ?? 0, diff --git a/backend/shared/src/utils.ts b/backend/shared/src/utils.ts index e09494ff3c..28a5002bcf 100644 --- a/backend/shared/src/utils.ts +++ b/backend/shared/src/utils.ts @@ -21,8 +21,8 @@ import { convertAnswer, convertContract } from 'common/supabase/contracts' import { Row, tsToMillis } from 'common/supabase/utils' import { log } from 'shared/monitoring/log' import { metrics } from 'shared/monitoring/metrics' -import { convertBet } from 'common/supabase/bets' import { convertLiquidity } from 'common/supabase/liquidity' +import { ContractMetric } from 'common/contract-metric' export { metrics } export { log } @@ -129,24 +129,27 @@ export const getContract = async ( return contract } -export const getContractAndBetsAndLiquidities = async ( +export const getContractAndMetricsAndLiquidities = async ( pg: SupabaseTransaction, unresolvedContract: MarketContract, answerId: string | undefined ) => { const { id: contractId, mechanism, outcomeType } = unresolvedContract + const isMulti = mechanism === 'cpmm-multi-1' // Filter out initial liquidity if set up with special liquidity per answer. const filterAnte = - mechanism === 'cpmm-multi-1' && + isMulti && outcomeType !== 'NUMBER' && unresolvedContract.specialLiquidityPerAnswer const results = await pg.multi( `select ${contractColumnsToSelect} from contracts where id = $1; select * from answers where contract_id = $1 order by index; - select * from contract_bets - where contract_id = $1 - and (shares != 0 or loan_amount != 0) - ${answerId ? `and data->>'answerId' = $2` : ''}; + select data from user_contract_metrics + where contract_id = $1 and + ${isMulti ? 'answer_id is not null and' : ''} + ($2 is null or exists (select 1 from user_contract_metrics ucm + where ucm.contract_id = $1 + and ucm.answer_id = $2)); select * from contract_liquidity where contract_id = $1 ${ filterAnte ? `and data->>'answerId' = $2` : '' };`, @@ -154,15 +157,16 @@ export const getContractAndBetsAndLiquidities = async ( ) const contract = first(results[0].map(convertContract)) as MarketContract - if (!contract) throw new APIError(500, 'Contract not found') + if (!contract) throw new APIError(404, 'Contract not found') const answers = results[1].map(convertAnswer) if ('answers' in contract) { contract.answers = answers } - const bets = results[2].map(convertBet) + // We don't get the summary metric, we recreate them from all the answer metrics + const contractMetrics = results[2].map((row) => row.data as ContractMetric) const liquidities = results[3].map(convertLiquidity) - return { contract, bets, liquidities } + return { contract, contractMetrics, liquidities } } export const getContractSupabase = async (contractId: string) => { diff --git a/backend/shared/src/websockets/helpers.ts b/backend/shared/src/websockets/helpers.ts index 88f121aa9c..91de827f53 100644 --- a/backend/shared/src/websockets/helpers.ts +++ b/backend/shared/src/websockets/helpers.ts @@ -39,7 +39,7 @@ export function broadcastOrders(bets: LimitBet[]) { broadcast(`contract/${contractId}/orders`, { bets }) } -export function broadcastUpdatedMetrics(metrics: ContractMetric[]) { +export function broadcastUpdatedMetrics(metrics: Omit[]) { if (metrics.length === 0) return const { contractId } = metrics[0] const metricsByUser = groupBy(metrics, (m) => m.userId) diff --git a/common/src/calculate-metrics.ts b/common/src/calculate-metrics.ts index 87bba3329b..61f41cec4c 100644 --- a/common/src/calculate-metrics.ts +++ b/common/src/calculate-metrics.ts @@ -1,6 +1,7 @@ import { cloneDeep, Dictionary, + first, groupBy, min, orderBy, @@ -380,10 +381,10 @@ export const calculateUserMetricsWithNewBetsOnly = ( } } -export const calculateProfitMetricsWithProb = < +export const calculateProfitMetricsAtProbOrCancel = < T extends Omit | ContractMetric >( - newProb: number, + newState: number | 'CANCEL', um: T ) => { const { @@ -393,15 +394,20 @@ export const calculateProfitMetricsWithProb = < totalShares, hasNoShares, hasYesShares, + invested, } = um const soldOut = !hasNoShares && !hasYesShares - const payout = soldOut - ? 0 - : maxSharesOutcome - ? totalShares[maxSharesOutcome] * - (maxSharesOutcome === 'NO' ? 1 - newProb : newProb) - : 0 - const profit = payout + totalAmountSold - totalAmountInvested + const payout = + newState === 'CANCEL' + ? invested + : soldOut + ? 0 + : maxSharesOutcome + ? totalShares[maxSharesOutcome] * + (maxSharesOutcome === 'NO' ? 1 - newState : newState) + : 0 + const profit = + newState === 'CANCEL' ? 0 : payout + totalAmountSold - totalAmountInvested const profitPercent = floatingEqual(totalAmountInvested, 0) ? 0 : (profit / totalAmountInvested) * 100 @@ -556,47 +562,58 @@ export const calculateUpdatedMetricsForContracts = ( }[] ) => { const metricsByContract: Dictionary[]> = {} - const contracts: Contract[] = [] for (const { contract, metrics } of contractsWithMetrics) { + if (metrics.length === 0) continue + const contractId = contract.id - const userId = metrics[0].userId - contracts.push(contract) - - if (contract.mechanism === 'cpmm-1') { - // For binary markets, update metrics with current probability - const metric = metrics.find((m) => m.answerId === null) - if (metric) { - metricsByContract[contractId] = [ - calculateProfitMetricsWithProb(contract.prob, metric), - ] - } - } else if (contract.mechanism === 'cpmm-multi-1') { - // For multiple choice markets, update each answer's metrics and compute summary - const answerMetrics = metrics.filter((m) => m.answerId !== null) - - const updatedAnswerMetrics = answerMetrics.map((m) => { - const answer = contract.answers.find((a) => a.id === m.answerId) - return answer - ? calculateProfitMetricsWithProb( - answer.resolution === 'YES' - ? 1 - : answer.resolution === 'NO' - ? 0 - : answer.prob, - m - ) - : m - }) - // Calculate summary metrics - const summaryMetric = getDefaultMetric(userId, contractId, null) - updatedAnswerMetrics.forEach((m) => - applyMetricToSummary(m, summaryMetric, true) - ) - metricsByContract[contractId] = [...updatedAnswerMetrics, summaryMetric] - } + // Group metrics by userId + const metricsByUser = groupBy(metrics, 'userId') + + metricsByContract[contractId] = Object.entries(metricsByUser).flatMap( + ([userId, userMetrics]) => { + if (contract.mechanism === 'cpmm-1') { + const state = + contract.resolution === 'CANCEL' ? 'CANCEL' : contract.prob + // For binary markets, update metrics with current probability + const metric = first(userMetrics) + return metric + ? [calculateProfitMetricsAtProbOrCancel(state, metric)] + : [] + } else if (contract.mechanism === 'cpmm-multi-1') { + // For multiple choice markets, update each answer's metrics and compute summary per user + const answerMetrics = userMetrics.filter((m) => m.answerId !== null) + + const updatedAnswerMetrics = answerMetrics.map((m) => { + const answer = contract.answers.find((a) => a.id === m.answerId) + if (answer) { + const state = + contract.resolution === 'CANCEL' || + answer.resolution === 'CANCEL' + ? 'CANCEL' + : answer.resolution === 'YES' + ? 1 + : answer.resolution === 'NO' + ? 0 + : answer.prob + return calculateProfitMetricsAtProbOrCancel(state, m) + } + return m + }) + + // Calculate summary metrics for this user + const summaryMetric = getDefaultMetric(userId, contractId, null) + updatedAnswerMetrics.forEach((m) => + applyMetricToSummary(m, summaryMetric, true) + ) + + return [...updatedAnswerMetrics, summaryMetric] + } + return [] + } + ) } - return { metricsByContract, contracts } + return { metricsByContract } } diff --git a/common/src/loans.ts b/common/src/loans.ts index 41bd720ba0..7ed267d17a 100644 --- a/common/src/loans.ts +++ b/common/src/loans.ts @@ -22,9 +22,7 @@ export const getUserLoanUpdates = ( export const overLeveraged = (loanTotal: number, investmentValue: number) => loanTotal / investmentValue >= 8 -export const isUserEligibleForLoan = ( - portfolio: PortfolioMetrics & { userId: string } -) => { +export const isUserEligibleForLoan = (portfolio: PortfolioMetrics) => { const { investmentValue, loanTotal } = portfolio return investmentValue > 0 && !overLeveraged(loanTotal ?? 0, investmentValue) } diff --git a/common/src/payouts-fixed.ts b/common/src/payouts-fixed.ts index 77eb14f660..92b33306cb 100644 --- a/common/src/payouts-fixed.ts +++ b/common/src/payouts-fixed.ts @@ -1,76 +1,77 @@ +import { Answer } from './answer' +import { CPMMContract } from './contract' +import { LiquidityProvision } from './liquidity-provision' +import { PayoutInfo } from './payouts' +import { ContractMetric } from './contract-metric' import { sumBy } from 'lodash' -import { Bet } from './bet' import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' -import { - CPMMContract, - CPMMMultiContract, - CPMMNumericContract, -} from './contract' -import { LiquidityProvision } from './liquidity-provision' -import { Answer } from './answer' export const getFixedCancelPayouts = ( - contract: CPMMContract | CPMMMultiContract | CPMMNumericContract, - bets: Bet[], + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[] -) => { - const liquidityPayouts = liquidities.map((lp) => ({ - userId: lp.userId, - payout: lp.amount, +): PayoutInfo => { + const traderPayouts = contractMetrics.map((metric) => ({ + userId: metric.userId, + payout: metric.invested, })) - const payouts = bets.map((bet) => ({ - userId: bet.userId, - // We keep the platform fee. - payout: bet.amount - bet.fees.platformFee, + const liquidityPayouts = liquidities.map((liquidity) => ({ + userId: liquidity.userId, + payout: liquidity.amount, })) + // TODO we don't claw back fees from creators here, but we used to when using bets - // Creator pays back all creator fees for N/A resolution. - const creatorFees = sumBy(bets, (b) => b.fees.creatorFee) - payouts.push({ - userId: contract.creatorId, - payout: -creatorFees, - }) - - return { payouts, liquidityPayouts } + return { + traderPayouts, + liquidityPayouts, + } } export const getStandardFixedPayouts = ( - outcome: string, - contract: - | CPMMContract - | (CPMMMultiContract & { shouldAnswersSumToOne: false }), - bets: Bet[], + outcome: string, // Will be 'YES' or 'NO' + contract: CPMMContract, + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[] -) => { - const winningBets = bets.filter((bet) => bet.outcome === outcome) - - const payouts = winningBets.map(({ userId, shares }) => ({ - userId, - payout: shares, - })) +): PayoutInfo => { + const traderPayouts = contractMetrics.map((metric) => { + const shares = metric.totalShares[outcome] || 0 + return { + userId: metric.userId, + payout: shares, + } + }) - const liquidityPayouts = - contract.mechanism === 'cpmm-1' - ? getLiquidityPoolPayouts(contract, outcome, liquidities) - : [] + const liquidityPayouts = getLiquidityPoolPayouts( + contract, + outcome, + liquidities + ) - return { payouts, liquidityPayouts } + return { + traderPayouts, + liquidityPayouts, + } } export const getMultiFixedPayouts = ( answers: Answer[], resolutions: { [answerId: string]: number }, - bets: Bet[], + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[] -) => { - const payouts = bets - .map(({ userId, shares, answerId, outcome }) => { - const weight = answerId ? resolutions[answerId] ?? 0 : 0 - const outcomeWeight = outcome === 'YES' ? weight : 1 - weight - const payout = shares * outcomeWeight +): PayoutInfo => { + const traderPayouts = contractMetrics + .map((metric) => { + let payout = 0 + const answer = answers.find((answer) => answer.id === metric.answerId) + if (!answer) return { userId: metric.userId, payout } + for (const outcome of ['YES', 'NO']) { + const weight = resolutions[answer.id] ?? 0 + const outcomeWeight = outcome === 'YES' ? weight : 1 - weight + const shares = metric.totalShares[outcome] ?? 0 + payout += shares * outcomeWeight + } return { - userId, + userId: metric.userId, payout, } }) @@ -81,30 +82,40 @@ export const getMultiFixedPayouts = ( resolutions, liquidities ) - return { payouts, liquidityPayouts } + + return { + traderPayouts, + liquidityPayouts, + } } export const getIndependentMultiYesNoPayouts = ( answer: Answer, - outcome: string, - bets: Bet[], + outcome: 'YES' | 'NO', + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[] -) => { - const winningBets = bets.filter((bet) => bet.outcome === outcome) - - const payouts = winningBets.map(({ userId, shares }) => ({ - userId, - payout: shares, - })) - +): PayoutInfo => { + const traderPayouts = contractMetrics + .filter((metric) => metric.answerId === answer.id) + .map((metric) => { + const shares = metric.totalShares[outcome] || 0 + return { + userId: metric.userId, + payout: shares, + } + }) const resolution = outcome === 'YES' ? 1 : 0 + const liquidityPayouts = getIndependentMultiLiquidityPoolPayouts( answer, resolution, liquidities ) - return { payouts, liquidityPayouts } + return { + traderPayouts, + liquidityPayouts, + } } export const getLiquidityPoolPayouts = ( @@ -159,22 +170,23 @@ export const getMultiLiquidityPoolPayouts = ( } export const getMktFixedPayouts = ( - contract: - | CPMMContract - | (CPMMMultiContract & { shouldAnswersSumToOne: false }), - bets: Bet[], + contract: CPMMContract, + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[], resolutionProbability: number -) => { +): PayoutInfo => { const outcomeProbs = { YES: resolutionProbability, NO: 1 - resolutionProbability, } - const payouts = bets.map(({ userId, outcome, shares }) => { - const p = outcomeProbs[outcome as 'YES' | 'NO'] ?? 0 - const payout = p * shares - return { userId, payout } + const traderPayouts = contractMetrics.map((metric) => { + const yesShares = metric.totalShares['YES'] || 0 + const noShares = metric.totalShares['NO'] || 0 + return { + userId: metric.userId, + payout: yesShares * outcomeProbs.YES + noShares * outcomeProbs.NO, + } }) const liquidityPayouts = @@ -182,25 +194,32 @@ export const getMktFixedPayouts = ( ? getLiquidityPoolProbPayouts(contract, outcomeProbs, liquidities) : [] - return { payouts, liquidityPayouts } + return { + traderPayouts, + liquidityPayouts, + } } export const getIndependentMultiMktPayouts = ( answer: Answer, - bets: Bet[], + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[], resolutionProbability: number -) => { +): PayoutInfo => { const outcomeProbs = { YES: resolutionProbability, NO: 1 - resolutionProbability, } - - const payouts = bets.map(({ userId, outcome, shares }) => { - const p = outcomeProbs[outcome as 'YES' | 'NO'] ?? 0 - const payout = p * shares - return { userId, payout } - }) + const traderPayouts = contractMetrics + .filter((metric) => metric.answerId === answer.id) + .map((metric) => { + const yesShares = metric.totalShares['YES'] ?? 0 + const noShares = metric.totalShares['NO'] ?? 0 + return { + userId: metric.userId, + payout: yesShares * outcomeProbs.YES + noShares * outcomeProbs.NO, + } + }) const liquidityPayouts = getIndependentMultiLiquidityPoolPayouts( answer, @@ -208,7 +227,10 @@ export const getIndependentMultiMktPayouts = ( liquidities ) - return { payouts, liquidityPayouts } + return { + traderPayouts, + liquidityPayouts, + } } export const getLiquidityPoolProbPayouts = ( diff --git a/common/src/payouts.ts b/common/src/payouts.ts index 119f86d05d..88b9ed0d18 100644 --- a/common/src/payouts.ts +++ b/common/src/payouts.ts @@ -1,6 +1,5 @@ import { sumBy, groupBy, mapValues } from 'lodash' -import { Bet } from './bet' import { Contract, CPMMContract, CPMMMultiContract } from './contract' import { LiquidityProvision } from './liquidity-provision' import { @@ -13,17 +12,17 @@ import { } from './payouts-fixed' import { getProbability } from './calculate' import { Answer } from './answer' +import { ContractMetric } from './contract-metric' export type Payout = { userId: string payout: number } - -export const getLoanPayouts = (bets: Bet[]): Payout[] => { - const betsWithLoans = bets.filter((bet) => bet.loanAmount) - const betsByUser = groupBy(betsWithLoans, (bet) => bet.userId) - const loansByUser = mapValues(betsByUser, (bets) => - sumBy(bets, (bet) => -(bet.loanAmount ?? 0)) +export const getLoanPayouts = (contractMetrics: ContractMetric[]): Payout[] => { + const metricsWithLoans = contractMetrics.filter((metric) => metric.loan) + const metricsByUser = groupBy(metricsWithLoans, (metric) => metric.userId) + const loansByUser = mapValues(metricsByUser, (metrics) => + sumBy(metrics, (metric) => -(metric.loan ?? 0)) ) return Object.entries(loansByUser).map(([userId, payout]) => ({ userId, @@ -37,14 +36,14 @@ export const groupPayoutsByUser = (payouts: Payout[]) => { } export type PayoutInfo = { - payouts: Payout[] + traderPayouts: Payout[] liquidityPayouts: Payout[] } export const getPayouts = ( outcome: string | undefined, contract: Contract, - bets: Bet[], + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[], resolutions?: { [outcome: string]: number @@ -57,7 +56,7 @@ export const getPayouts = ( return getFixedPayouts( outcome, contract, - bets, + contractMetrics, liquidities, resolutionProbability ?? prob ) @@ -75,14 +74,14 @@ export const getPayouts = ( answer, outcome, contract as CPMMMultiContract, - bets, + contractMetrics, liquidities, resolutionProbability ?? answer.prob ) } if (contract.mechanism === 'cpmm-multi-1') { if (outcome === 'CANCEL') { - return getFixedCancelPayouts(contract, bets, liquidities) + return getFixedCancelPayouts(contractMetrics, liquidities) } if (!resolutions) { throw new Error('getPayouts: resolutions required for cpmm-multi-1') @@ -91,7 +90,7 @@ export const getPayouts = ( return getMultiFixedPayouts( contract.answers, resolutions, - bets, + contractMetrics, liquidities ) } @@ -100,27 +99,30 @@ export const getPayouts = ( export const getFixedPayouts = ( outcome: string | undefined, - contract: - | CPMMContract - | (CPMMMultiContract & { shouldAnswersSumToOne: false }), - bets: Bet[], + contract: CPMMContract, + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[], resolutionProbability: number ) => { switch (outcome) { case 'YES': case 'NO': - return getStandardFixedPayouts(outcome, contract, bets, liquidities) + return getStandardFixedPayouts( + outcome, + contract, + contractMetrics, + liquidities + ) case 'MKT': return getMktFixedPayouts( contract, - bets, + contractMetrics, liquidities, resolutionProbability ) default: case 'CANCEL': - return getFixedCancelPayouts(contract, bets, liquidities) + return getFixedCancelPayouts(contractMetrics, liquidities) } } @@ -128,7 +130,7 @@ export const getIndependentMultiFixedPayouts = ( answer: Answer, outcome: string | undefined, contract: CPMMMultiContract, - bets: Bet[], + contractMetrics: ContractMetric[], liquidities: LiquidityProvision[], resolutionProbability: number ) => { @@ -146,18 +148,18 @@ export const getIndependentMultiFixedPayouts = ( return getIndependentMultiYesNoPayouts( answer, outcome, - bets, + contractMetrics, filteredLiquidities ) case 'MKT': return getIndependentMultiMktPayouts( answer, - bets, + contractMetrics, filteredLiquidities, resolutionProbability ) default: case 'CANCEL': - return getFixedCancelPayouts(contract, bets, filteredLiquidities) + return getFixedCancelPayouts(contractMetrics, filteredLiquidities) } } diff --git a/common/src/portfolio-metrics.ts b/common/src/portfolio-metrics.ts index ebd8fce3f4..e50cc89d05 100644 --- a/common/src/portfolio-metrics.ts +++ b/common/src/portfolio-metrics.ts @@ -9,6 +9,7 @@ export type PortfolioMetrics = { loanTotal: number timestamp: number profit?: number + userId: string } export type LivePortfolioMetrics = PortfolioMetrics & { dailyProfit: number diff --git a/common/src/supabase/portfolio-metrics.ts b/common/src/supabase/portfolio-metrics.ts index 161584b49b..3483fc769b 100644 --- a/common/src/supabase/portfolio-metrics.ts +++ b/common/src/supabase/portfolio-metrics.ts @@ -50,5 +50,6 @@ export const convertPortfolioHistory = ( cashInvestmentValue: row.cash_investment_value ?? 0, totalCashDeposits: row.total_cash_deposits ?? 0, cashBalance: row.cash_balance ?? 0, + userId: row.user_id, } as PortfolioMetrics } diff --git a/web/hooks/use-saved-contract-metrics.ts b/web/hooks/use-saved-contract-metrics.ts index aa2b48c90a..36acc6b882 100644 --- a/web/hooks/use-saved-contract-metrics.ts +++ b/web/hooks/use-saved-contract-metrics.ts @@ -54,10 +54,10 @@ export const useSavedContractMetrics = ( useApiSubscription({ topics: [`contract/${contract.id}/user-metrics/${user?.id}`], onBroadcast: (msg) => { - const metrics = (msg.data.metrics as ContractMetric[]).filter((m) => - answerId ? m.answerId === answerId : true + const metrics = (msg.data.metrics as Omit[]).filter( + (m) => (answerId ? m.answerId === answerId : true) ) - if (metrics.length > 0) setSavedMetrics(metrics) + if (metrics.length > 0) setSavedMetrics(metrics as ContractMetric[]) }, enabled: !!user?.id, }) From f5d9374870bffff830d5c37d946d94559ceb277d Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 15:35:24 -0800 Subject: [PATCH 11/21] Optimize update metrics transaction --- backend/api/src/request-loan.ts | 94 ++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/backend/api/src/request-loan.ts b/backend/api/src/request-loan.ts index bf997234a0..a4234fdc82 100644 --- a/backend/api/src/request-loan.ts +++ b/backend/api/src/request-loan.ts @@ -1,23 +1,27 @@ import { APIError, type APIHandler } from './helpers/endpoint' -import { - createSupabaseDirectClient, - SupabaseTransaction, -} from 'shared/supabase/init' +import { createSupabaseDirectClient } from 'shared/supabase/init' import { createLoanIncomeNotification } from 'shared/create-notification' import { getUser, log } from 'shared/utils' import { getUserLoanUpdates, isUserEligibleForLoan } from 'common/loans' import * as dayjs from 'dayjs' import * as utc from 'dayjs/plugin/utc' import * as timezone from 'dayjs/plugin/timezone' -import { bulkUpdateContractMetrics } from 'shared/helpers/user-contract-metrics' +import { bulkUpdateContractMetricsQuery } from 'shared/helpers/user-contract-metrics' dayjs.extend(utc) dayjs.extend(timezone) import { LoanTxn } from 'common/txn' -import { runTxnFromBank } from 'shared/txn/run-txn' +import { txnToRow } from 'shared/txn/run-txn' import { filterDefined } from 'common/util/array' import { getUnresolvedContractMetricsContractsAnswers } from 'shared/update-user-portfolio-histories-core' import { keyBy } from 'lodash' import { convertPortfolioHistory } from 'common/supabase/portfolio-metrics' +import { getInsertQuery } from 'shared/supabase/utils' +import { + broadcastUserUpdates, + bulkIncrementBalancesQuery, + UserUpdate, +} from 'shared/supabase/users' +import { betsQueue } from 'shared/helpers/fn-queue' export const requestLoan: APIHandler<'request-loan'> = async (_, auth) => { const pg = createSupabaseDirectClient() @@ -40,40 +44,47 @@ export const requestLoan: APIHandler<'request-loan'> = async (_, auth) => { } }) ) - await pg.tx(async (tx) => { - await payUserLoan(user.id, payout, tx) - await bulkUpdateContractMetrics(updatedMetrics, tx) - }) + const bulkUpdateContractMetricsQ = + bulkUpdateContractMetricsQuery(updatedMetrics) + const { txnQuery, balanceUpdateQuery } = payUserLoan(user.id, payout) + + const { userUpdates } = await betsQueue.enqueueFn(async () => { + const startOfDay = dayjs() + .tz('America/Los_Angeles') + .startOf('day') + .toISOString() + + const res = await pg.oneOrNone( + `select 1 as count from txns + where to_id = $1 + and category = 'LOAN' + and created_time >= $2 + limit 1; + `, + [auth.uid, startOfDay] + ) + if (res) { + throw new APIError(400, 'Already awarded loan today') + } + return pg.tx(async (tx) => { + const res = await tx.multi( + `${balanceUpdateQuery}; + ${txnQuery}; + ${bulkUpdateContractMetricsQ}` + ) + const userUpdates = res[0] as UserUpdate[] + return { userUpdates } + }) + }, [auth.uid]) + broadcastUserUpdates(userUpdates) log(`Paid out ${payout} to user ${user.id}.`) await createLoanIncomeNotification(user, payout) - return { payout } + return result } -const payUserLoan = async ( - userId: string, - payout: number, - tx: SupabaseTransaction -) => { - const startOfDay = dayjs() - .tz('America/Los_Angeles') - .startOf('day') - .toISOString() - - // make sure we don't already have a txn for this user/questType - const { count } = await tx.one( - `select count(*) from txns - where to_id = $1 - and category = 'LOAN' - and created_time >= $2 - limit 1`, - [userId, startOfDay] - ) - - if (count) { - throw new APIError(400, 'Already awarded loan today') - } - - const loanTxn: Omit = { +const payUserLoan = (userId: string, payout: number) => { + const loanTxn: Omit = { + fromId: 'BANK', fromType: 'BANK', toId: userId, toType: 'USER', @@ -85,7 +96,16 @@ const payUserLoan = async ( countsAsProfit: true, }, } - await runTxnFromBank(tx, loanTxn, true) + const balanceUpdate = { + id: loanTxn.toId, + balance: payout, + } + const balanceUpdateQuery = bulkIncrementBalancesQuery([balanceUpdate]) + const txnQuery = getInsertQuery('txns', txnToRow(loanTxn)) + return { + txnQuery, + balanceUpdateQuery, + } } export const getNextLoanAmountResults = async (userId: string) => { From 48e41b0e3bbadd53285c31abf33cc15033c60b11 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 15:58:09 -0800 Subject: [PATCH 12/21] Pay back loan in multi-sell --- backend/api/src/multi-sell.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/backend/api/src/multi-sell.ts b/backend/api/src/multi-sell.ts index 44b9459494..35eebda847 100644 --- a/backend/api/src/multi-sell.ts +++ b/backend/api/src/multi-sell.ts @@ -3,9 +3,8 @@ import { APIError, type APIHandler } from './helpers/endpoint' import { onCreateBets } from 'api/on-create-bet' import { executeNewBetResult } from 'api/place-bet' import { getContract, getUser, log } from 'shared/utils' -import { groupBy, mapValues, sum, sumBy } from 'lodash' +import { groupBy, mapValues, sumBy } from 'lodash' import { getCpmmMultiSellSharesInfo } from 'common/sell-bet' -import { incrementBalance } from 'shared/supabase/users' import { runTransactionWithRetries } from 'shared/transact-with-retries' import { convertBet } from 'common/supabase/bets' import { betsQueue } from 'shared/helpers/fn-queue' @@ -72,8 +71,11 @@ const multiSellMain: APIHandler<'multi-sell'> = async (props, auth) => { ) const loanAmountByAnswerId = mapValues( - groupBy(userBets, 'answerId'), - (bets) => sumBy(bets, (bet) => bet.loanAmount ?? 0) + groupBy( + allMyMetrics.filter((m) => m.answerId !== null), + 'answerId' + ), + (metrics) => sumBy(metrics, (bet) => bet.loan ?? 0) ) const nonRedemptionBetsByAnswerId = groupBy( userBets.filter((bet) => bet.shares !== 0), @@ -115,13 +117,6 @@ const multiSellMain: APIHandler<'multi-sell'> = async (props, auth) => { ) results.push(result) } - const bets = results.flatMap((r) => r.fullBets) - const loanPaid = sum(Object.values(loanAmountByAnswerId)) - if (loanPaid > 0 && bets.length > 0) { - await incrementBalance(pgTrans, uid, { - balance: -loanPaid, - }) - } return results }) From 2cb64b1e7c42d50c8c4f1dba4da64915d99b786e Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 16:04:59 -0800 Subject: [PATCH 13/21] Remove nextLoanCached and fix redemption loan amount --- backend/api/src/redeem-shares.ts | 8 ++++++-- backend/shared/src/create-user-main.ts | 1 - common/src/user.ts | 1 - web/components/profile/loans-modal.tsx | 12 +++++++----- web/hooks/use-has-received-loan.ts | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/api/src/redeem-shares.ts b/backend/api/src/redeem-shares.ts index 7571c95ada..77303a9e3e 100644 --- a/backend/api/src/redeem-shares.ts +++ b/backend/api/src/redeem-shares.ts @@ -51,6 +51,7 @@ export const redeemShares = async ( for (const userId of userIds) { // This should work for any sum-to-one cpmm-multi contract, as well if (contract.outcomeType === 'NUMBER') { + const myMetrics = contractMetrics.filter((m) => m.userId === userId) const userNonRedemptionBetsByAnswer = groupBy( bets.filter((bet) => bet.shares !== 0 && bet.userId === userId), (bet) => bet.answerId @@ -67,8 +68,11 @@ export const redeemShares = async ( const minShares = min(allShares) ?? 0 if (minShares > 0 && allShares.length === contract.answers.length) { const loanAmountByAnswerId = mapValues( - groupBy(bets, 'answerId'), - (bets) => sumBy(bets, (bet) => bet.loanAmount ?? 0) + groupBy( + myMetrics.filter((m) => m.answerId !== null), + 'answerId' + ), + (metrics) => sumBy(metrics, (m) => m.loan ?? 0) ) const saleBets = getSellAllRedemptionPreliminaryBets( diff --git a/backend/shared/src/create-user-main.ts b/backend/shared/src/create-user-main.ts index 38a6adaf19..eae0238fa8 100644 --- a/backend/shared/src/create-user-main.ts +++ b/backend/shared/src/create-user-main.ts @@ -119,7 +119,6 @@ export const createUserMain = async ( totalDeposits: 0, totalCashDeposits: 0, createdTime: Date.now(), - nextLoanCached: 0, streakForgiveness: 1, shouldShowWelcome: true, creatorTraders: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, diff --git a/common/src/user.ts b/common/src/user.ts index c2dc61448f..d0182620f9 100644 --- a/common/src/user.ts +++ b/common/src/user.ts @@ -32,7 +32,6 @@ export type User = { /**@deprecated 2023-01-015 */ fractionResolvedCorrectly?: number - nextLoanCached: number /** @deprecated */ followerCountCached?: number diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 21eea5ca4f..ec759141d2 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -5,6 +5,7 @@ import { ENV_CONFIG, TRADE_TERM } from 'common/envs/constants' import { LOAN_DAILY_RATE, overLeveraged } from 'common/loans' import { useHasReceivedLoanToday } from 'web/hooks/use-has-received-loan' import { useIsEligibleForLoans } from 'web/hooks/use-is-eligible-for-loans' +import { useAPIGetter } from 'web/hooks/use-api-getter' export function LoansModal(props: { user: User @@ -14,7 +15,8 @@ export function LoansModal(props: { const { isOpen, user, setOpen } = props const { receivedLoanToday } = useHasReceivedLoanToday(user) const { latestPortfolio, isEligible } = useIsEligibleForLoans(user.id) - + const { data } = useAPIGetter('get-next-loan-amount', { userId: user.id }) + const nextLoanAmount = data?.amount ?? 0 return ( @@ -23,10 +25,10 @@ export function LoansModal(props: { {receivedLoanToday ? ( You have already received your loan today. Come back tomorrow for - {user.nextLoanCached > 0 && - ` ${ENV_CONFIG.moneyMoniker}${Math.floor(user.nextLoanCached)}!`} + {nextLoanAmount > 0 && + ` ${ENV_CONFIG.moneyMoniker}${Math.floor(nextLoanAmount)}!`} - ) : !isEligible || user.nextLoanCached < 1 ? ( + ) : !isEligible || nextLoanAmount < 1 ? ( You're not eligible for a loan right now.{' '} {!user?.lastBetTime || !latestPortfolio @@ -38,7 +40,7 @@ export function LoansModal(props: { latestPortfolio.investmentValue ) ? `You are over-leveraged. Sell some of your positions or place some good ${TRADE_TERM}s to become eligible.` - : latestPortfolio.loanTotal && user.nextLoanCached < 1 + : latestPortfolio.loanTotal && nextLoanAmount < 1 ? `We've already loaned you up to the current value of your ${TRADE_TERM}s. Place some more ${TRADE_TERM}s to become eligible again.` : ''} diff --git a/web/hooks/use-has-received-loan.ts b/web/hooks/use-has-received-loan.ts index 2c891e1da0..ec7caf4767 100644 --- a/web/hooks/use-has-received-loan.ts +++ b/web/hooks/use-has-received-loan.ts @@ -8,7 +8,7 @@ import { api } from 'web/lib/api/api' export const useHasReceivedLoanToday = (user: User) => { const startOfDay = dayjs().tz('America/Los_Angeles').startOf('day').valueOf() - // user has either received a loan today or nextLoanCached is 0 + // user has either received a loan today or nextLoan is 0 const [lastLoanReceived, setLastLoanReceived] = usePersistentLocalState< number | undefined >(undefined, `last-loan-${user.id}`) From 80d52dd60b598589380e42a16cb6b721568f3a50 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 16:08:08 -0800 Subject: [PATCH 14/21] Simplify --- backend/api/src/multi-sell.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/api/src/multi-sell.ts b/backend/api/src/multi-sell.ts index 35eebda847..0035e70f01 100644 --- a/backend/api/src/multi-sell.ts +++ b/backend/api/src/multi-sell.ts @@ -3,7 +3,7 @@ import { APIError, type APIHandler } from './helpers/endpoint' import { onCreateBets } from 'api/on-create-bet' import { executeNewBetResult } from 'api/place-bet' import { getContract, getUser, log } from 'shared/utils' -import { groupBy, mapValues, sumBy } from 'lodash' +import { groupBy, keyBy, keyBy, mapValues, sumBy } from 'lodash' import { getCpmmMultiSellSharesInfo } from 'common/sell-bet' import { runTransactionWithRetries } from 'shared/transact-with-retries' import { convertBet } from 'common/supabase/bets' @@ -71,12 +71,13 @@ const multiSellMain: APIHandler<'multi-sell'> = async (props, auth) => { ) const loanAmountByAnswerId = mapValues( - groupBy( + keyBy( allMyMetrics.filter((m) => m.answerId !== null), 'answerId' ), - (metrics) => sumBy(metrics, (bet) => bet.loan ?? 0) + (m) => m.loan ?? 0 ) + const nonRedemptionBetsByAnswerId = groupBy( userBets.filter((bet) => bet.shares !== 0), (bet) => bet.answerId From 4220f897a75c42297c6fc92f221451f366f86619 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 16:19:25 -0800 Subject: [PATCH 15/21] Add proper loan numbers to multi-sell panel --- backend/api/src/multi-sell.ts | 2 +- web/components/answers/numeric-sell-panel.tsx | 14 ++++++++++++-- web/hooks/use-saved-contract-metrics.ts | 14 +++++++++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/backend/api/src/multi-sell.ts b/backend/api/src/multi-sell.ts index 0035e70f01..52b3f6e79a 100644 --- a/backend/api/src/multi-sell.ts +++ b/backend/api/src/multi-sell.ts @@ -3,7 +3,7 @@ import { APIError, type APIHandler } from './helpers/endpoint' import { onCreateBets } from 'api/on-create-bet' import { executeNewBetResult } from 'api/place-bet' import { getContract, getUser, log } from 'shared/utils' -import { groupBy, keyBy, keyBy, mapValues, sumBy } from 'lodash' +import { groupBy, keyBy, mapValues, sumBy } from 'lodash' import { getCpmmMultiSellSharesInfo } from 'common/sell-bet' import { runTransactionWithRetries } from 'shared/transact-with-retries' import { convertBet } from 'common/supabase/bets' diff --git a/web/components/answers/numeric-sell-panel.tsx b/web/components/answers/numeric-sell-panel.tsx index 62d33706d9..d666d44561 100644 --- a/web/components/answers/numeric-sell-panel.tsx +++ b/web/components/answers/numeric-sell-panel.tsx @@ -33,13 +33,16 @@ import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { api } from 'web/lib/api/api' import { MoneyDisplay } from '../bet/money-display' import { useUserContractBets } from 'web/hooks/use-user-bets' +import { useAllSavedContractMetrics } from 'web/hooks/use-saved-contract-metrics' +import { ContractMetric } from 'common/contract-metric' export const NumericSellPanel = (props: { contract: CPMMNumericContract userBets: Bet[] + contractMetrics: ContractMetric[] cancel: () => void }) => { - const { contract, userBets, cancel } = props + const { contract, userBets, contractMetrics, cancel } = props const { answers, min: minimum, max: maximum } = contract const isCashContract = contract.token === 'CASH' const expectedValue = getExpectedValue(contract) @@ -150,13 +153,16 @@ export const NumericSellPanel = (props: { const betsOnAnswersToSell = userBets.filter( (bet) => bet.answerId && answerIdsToSell.includes(bet.answerId) ) + const metricsOnAnswersToSell = contractMetrics.filter( + (m) => m.answerId && answerIdsToSell.includes(m.answerId) + ) const invested = getInvested(contract, betsOnAnswersToSell) const userBetsToSellByAnswerId = groupBy( betsOnAnswersToSell.filter((bet) => bet.shares !== 0), (bet) => bet.answerId ) - const loanPaid = sumBy(betsOnAnswersToSell, (bet) => bet.loanAmount ?? 0) + const loanPaid = sumBy(metricsOnAnswersToSell, (m) => m.loan ?? 0) const { newBetResults, updatedAnswers, totalFee } = calculateCpmmMultiArbitrageSellYesEqually( contract.answers, @@ -338,6 +344,9 @@ export const MultiNumericSellPanel = (props: { userId: string }) => { const { contract, userId } = props + const contractMetrics = useAllSavedContractMetrics(contract)?.filter( + (m) => m.answerId != null + ) const userBets = useUserContractBets(userId, contract.id) const [showSellPanel, setShowSellPanel] = useState(false) @@ -363,6 +372,7 @@ export const MultiNumericSellPanel = (props: { cancel={() => setShowSellPanel(false)} contract={contract} userBets={userBets} + contractMetrics={contractMetrics ?? []} /> )} diff --git a/web/hooks/use-saved-contract-metrics.ts b/web/hooks/use-saved-contract-metrics.ts index 36acc6b882..3f5df9689a 100644 --- a/web/hooks/use-saved-contract-metrics.ts +++ b/web/hooks/use-saved-contract-metrics.ts @@ -16,6 +16,16 @@ import { useBatchedGetter } from './use-batched-getter' export const useSavedContractMetrics = ( contract: Contract, answerId?: string +) => { + const allMetrics = useAllSavedContractMetrics(contract, answerId) + return allMetrics?.find((m) => + answerId ? m.answerId === answerId : m.answerId == null + ) +} + +export const useAllSavedContractMetrics = ( + contract: Contract, + answerId?: string ) => { const user = useUser() const [savedMetrics, setSavedMetrics] = usePersistentLocalState< @@ -62,9 +72,7 @@ export const useSavedContractMetrics = ( enabled: !!user?.id, }) - return savedMetrics?.find((m) => - answerId ? m.answerId === answerId : m.answerId == null - ) + return savedMetrics } export const useReadLocalContractMetrics = (contractId: string) => { From 505f42e484050afc8b598149d981ff4f8b9cad74 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 17:32:16 -0800 Subject: [PATCH 16/21] Save loan when recalculating metrics --- .../recalculate-multi-contract-metrics.ts | 44 -------- .../src/helpers/user-contract-metrics.ts | 20 +--- .../shared/src/update-user-metric-periods.ts | 10 +- .../src/update-user-metrics-with-bets.ts | 7 +- common/src/calculate-metrics.ts | 102 ++++-------------- common/src/calculate.ts | 2 +- .../leagues/mana-earned-breakdown.tsx | 4 +- 7 files changed, 36 insertions(+), 153 deletions(-) delete mode 100644 backend/scripts/recalculate-multi-contract-metrics.ts diff --git a/backend/scripts/recalculate-multi-contract-metrics.ts b/backend/scripts/recalculate-multi-contract-metrics.ts deleted file mode 100644 index 7859c65b84..0000000000 --- a/backend/scripts/recalculate-multi-contract-metrics.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { runScript } from './run-script' -import { CPMMMultiContract } from 'common/contract' -import { Bet } from 'common/bet' -import { updateContractMetricsForUsers } from 'shared/helpers/user-contract-metrics' -import { revalidateContractStaticProps } from 'shared/utils' - -if (require.main === module) { - runScript(async ({ pg }) => { - const resolvedContracts = await pg.map( - ` - select data from contracts - where resolution is not null - and mechanism = 'cpmm-multi-1' - and data->>'shouldAnswersSumToOne' = 'false' - `, - [], - (r) => r.data - ) - - console.log('got', resolvedContracts.length, 'contracts') - - for (const contract of resolvedContracts) { - const bets = await pg.map( - ` - select data from contract_bets - where contract_id = $1 - `, - [contract.id], - (r) => r.data - ) - - console.log( - 'updating', - contract.id, - contract.slug, - contract.question, - 'bets', - bets.length - ) - await updateContractMetricsForUsers(contract, bets) - await revalidateContractStaticProps(contract) - } - }) -} diff --git a/backend/shared/src/helpers/user-contract-metrics.ts b/backend/shared/src/helpers/user-contract-metrics.ts index a53708f075..e12845913f 100644 --- a/backend/shared/src/helpers/user-contract-metrics.ts +++ b/backend/shared/src/helpers/user-contract-metrics.ts @@ -1,9 +1,6 @@ -import { groupBy, uniq, uniqBy } from 'lodash' -import { Contract } from 'common/contract' -import { Bet } from 'common/bet' +import { uniq, uniqBy } from 'lodash' import { calculateAnswerMetricsWithNewBetsOnly, - calculateUserMetrics, MarginalBet, } from 'common/calculate-metrics' import { bulkUpsert, bulkUpsertQuery } from 'shared/supabase/utils' @@ -16,21 +13,6 @@ import { Tables } from 'common/supabase/utils' import { log } from 'shared/utils' import { filterDefined } from 'common/util/array' -export async function updateContractMetricsForUsers( - contract: Contract, - allContractBets: Bet[] -) { - const betsByUser = groupBy(allContractBets, 'userId') - const metrics: ContractMetric[] = [] - - for (const userId in betsByUser) { - const userBets = betsByUser[userId] - metrics.push(...calculateUserMetrics(contract, userBets, userId)) - } - - await bulkUpdateContractMetrics(metrics) -} - const getColumnsFromMetrics = (metrics: Omit[]) => metrics.map( (m) => diff --git a/backend/shared/src/update-user-metric-periods.ts b/backend/shared/src/update-user-metric-periods.ts index 7c302bd1a9..bcdbe072b8 100644 --- a/backend/shared/src/update-user-metric-periods.ts +++ b/backend/shared/src/update-user-metric-periods.ts @@ -14,8 +14,8 @@ import { filterDefined } from 'common/util/array' import { hasSignificantDeepChanges } from 'common/util/object' import { convertBet } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' -import { bulkUpdateData } from './supabase/utils' import { convertAnswer, convertContract } from 'common/supabase/contracts' +import { bulkUpdateData } from 'shared/supabase/utils' const CHUNK_SIZE = isProd() ? 400 : 10 export async function updateUserMetricPeriods( @@ -155,12 +155,13 @@ export async function updateUserMetricPeriods( userMetricRelevantBets, (b) => b.contractId ) + const currentMetricsForUser = currentMetricsByUserId[userId] ?? [] const freshMetrics = calculateMetricsByContractAndAnswer( metricRelevantBetsByContract, contractsById, - userId - ).flat() - const currentMetricsForUser = currentMetricsByUserId[userId] ?? [] + userId, + currentMetricsForUser + ) metricsByUser[userId] = uniqBy( [...freshMetrics, ...currentMetricsForUser], (m) => m.contractId + m.answerId @@ -205,6 +206,7 @@ export async function updateUserMetricPeriods( if (contractMetricUpdates.length > 0 && !skipUpdates) { log('Writing updates') + // await bulkUpdateContractMetrics(contractMetricUpdates, pg) await bulkUpdateData(pg, 'user_contract_metrics', contractMetricUpdates) .catch((e) => log.error('Error upserting contract metrics', e)) .then(() => diff --git a/backend/shared/src/update-user-metrics-with-bets.ts b/backend/shared/src/update-user-metrics-with-bets.ts index 9e8ba4fccc..27b21ff755 100644 --- a/backend/shared/src/update-user-metrics-with-bets.ts +++ b/backend/shared/src/update-user-metrics-with-bets.ts @@ -99,12 +99,13 @@ export async function updateUserMetricsWithBets( userMetricRelevantBets, (b) => b.contractId ) + const currentMetricsForUser = currentMetricsByUserId[user.id] ?? [] const freshMetrics = calculateMetricsByContractAndAnswer( metricRelevantBetsByContract, contractsById, - user.id - ).flat() - const currentMetricsForUser = currentMetricsByUserId[user.id] ?? [] + user.id, + currentMetricsForUser + ) contractMetricUpdates.push( ...freshMetrics.filter((freshMetric) => { const currentMetric = currentMetricsForUser.find( diff --git a/common/src/calculate-metrics.ts b/common/src/calculate-metrics.ts index 61f41cec4c..f0ee05817b 100644 --- a/common/src/calculate-metrics.ts +++ b/common/src/calculate-metrics.ts @@ -10,13 +10,11 @@ import { uniq, } from 'lodash' import { - calculatePayout, calculateTotalSpentAndShares, - getContractBetMetricsPerAnswer, + getContractBetMetricsPerAnswerWithoutLoans, } from './calculate' import { Bet, LimitBet } from './bet' import { Contract, CPMMMultiContract, CPMMMultiNumeric } from './contract' -import { User } from './user' import { computeFills } from './new-bet' import { CpmmState, getCpmmProbability } from './calculate-cpmm' import { removeUndefinedProps } from './util/object' @@ -24,42 +22,6 @@ import { floatingEqual, logit } from './util/math' import { ContractMetric } from 'common/contract-metric' import { noFees } from './fees' -export const computeInvestmentValue = ( - bets: Bet[], - contractsDict: { [k: string]: Contract } -) => { - let investmentValue = 0 - let cashInvestmentValue = 0 - for (const bet of bets) { - const contract = contractsDict[bet.contractId] - if (!contract || contract.isResolved) continue - - let payout - try { - payout = calculatePayout(contract, bet, 'MKT') - } catch (e) { - console.log( - 'contract', - contract.question, - contract.mechanism, - contract.id - ) - console.error(e) - payout = 0 - } - const value = payout - (bet.loanAmount ?? 0) - if (isNaN(value)) continue - - if (contract.token === 'CASH') { - cashInvestmentValue += value - } else { - investmentValue += value - } - } - - return { investmentValue, cashInvestmentValue } -} - export const computeInvestmentValueCustomProb = ( bets: Bet[], contract: Contract, @@ -77,17 +39,6 @@ export const computeInvestmentValueCustomProb = ( }) } -const getLoanTotal = ( - bets: Bet[], - contractsDict: { [k: string]: Contract } -) => { - return sumBy(bets, (bet) => { - const contract = contractsDict[bet.contractId] - if (!contract || contract.isResolved) return 0 - return bet.loanAmount ?? 0 - }) -} - export const ELASTICITY_BET_AMOUNT = 10000 // readjust with platform volume export const computeElasticity = ( @@ -214,39 +165,29 @@ const computeMultiCpmmElasticity = ( return min(elasticities) ?? 1_000_000 } -export const calculateNewPortfolioMetrics = ( - user: User, - contractsById: { [k: string]: Contract }, - unresolvedBets: Bet[] -) => { - const { investmentValue, cashInvestmentValue } = computeInvestmentValue( - unresolvedBets, - contractsById - ) - const loanTotal = getLoanTotal(unresolvedBets, contractsById) - return { - investmentValue, - cashInvestmentValue, - balance: user.balance, - cashBalance: user.cashBalance, - spiceBalance: user.spiceBalance, - totalDeposits: user.totalDeposits, - totalCashDeposits: user.totalCashDeposits, - loanTotal, - timestamp: Date.now(), - userId: user.id, - } -} - export const calculateMetricsByContractAndAnswer = ( betsByContractId: Dictionary, contractsById: Dictionary, - userId: string + userId: string, + currentMetrics: ContractMetric[] ) => { - return Object.entries(betsByContractId).map(([contractId, bets]) => { - const contract: Contract = contractsById[contractId] - return calculateUserMetrics(contract, bets, userId) + const newMetrics = Object.entries(betsByContractId).flatMap( + ([contractId, bets]) => { + const contract: Contract = contractsById[contractId] + return calculateUserMetricsWithouLoans(contract, bets, userId) + } + ) + // find loan amounts from current metrics and paste them into the new metrics + const newMetricsWithLoan = newMetrics.map((m) => { + const currentMetric = currentMetrics.find( + (cm) => + cm.contractId === m.contractId && + cm.answerId == m.answerId && + cm.userId === m.userId + ) + return { ...m, loan: currentMetric?.loan ?? m.loan } }) + return newMetricsWithLoan } // Produced from 0 filled limit orders @@ -261,13 +202,13 @@ export const isEmptyMetric = (m: ContractMetric) => { ) } -export const calculateUserMetrics = ( +export const calculateUserMetricsWithouLoans = ( contract: Contract, bets: Bet[], userId: string ) => { // ContractMetrics will have an answerId for every answer, and a null for the overall metrics. - const currentMetrics = getContractBetMetricsPerAnswer( + const currentMetrics = getContractBetMetricsPerAnswerWithoutLoans( contract, bets, 'answers' in contract ? contract.answers : undefined @@ -552,6 +493,7 @@ export const applyMetricToSummary = < // summaryMetric.hasNoShares // summaryMetric.hasYesShares // summaryMetric.hasShares + // summaryMetric.loan return summary } diff --git a/common/src/calculate.ts b/common/src/calculate.ts index 0c2109dc64..3083fecb8a 100644 --- a/common/src/calculate.ts +++ b/common/src/calculate.ts @@ -374,7 +374,7 @@ export const getContractBetMetrics = ( contractId: contract.id, } } -export const getContractBetMetricsPerAnswer = ( +export const getContractBetMetricsPerAnswerWithoutLoans = ( contract: Contract, bets: Bet[], answers?: Answer[] diff --git a/web/components/leagues/mana-earned-breakdown.tsx b/web/components/leagues/mana-earned-breakdown.tsx index 3f05b0a19a..c13c016dc9 100644 --- a/web/components/leagues/mana-earned-breakdown.tsx +++ b/web/components/leagues/mana-earned-breakdown.tsx @@ -13,7 +13,7 @@ import { LoadingIndicator } from '../widgets/loading-indicator' import { UserAvatarAndBadge } from '../widgets/user-link' import { Contract, contractPath } from 'common/contract' import { Bet } from 'common/bet' -import { calculateUserMetrics } from 'common/calculate-metrics' +import { calculateUserMetricsWithouLoans } from 'common/calculate-metrics' import { ProfitBadge } from '../profit-badge' import { ContractMetric } from 'common/contract-metric' import { useBetsOnce } from 'web/hooks/use-bets' @@ -73,7 +73,7 @@ export const ManaEarnedBreakdown = (props: { mapValues(betsByContract, (bets, contractId) => { const contract = contractsById[contractId] return contract - ? calculateUserMetrics(contract, bets, user.id).find( + ? calculateUserMetricsWithouLoans(contract, bets, user.id).find( (cm) => !cm.answerId ) : undefined From 89783e9ef93371c2074aaa8b1238db73ca21e5c0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 18:23:18 -0800 Subject: [PATCH 17/21] Use contract metric to show loan amount --- .../recalculate-user-contract-metrics.ts | 1 + .../src/update-user-metrics-with-bets.ts | 19 +++++++++---------- common/src/calculate-metrics.ts | 2 +- common/src/calculate.ts | 4 +--- common/src/redeem.ts | 18 ------------------ web/components/bet/contract-bets-table.tsx | 13 ++++++++++--- web/components/bet/user-bets-table.tsx | 9 +++++---- web/components/contract/contract-page.tsx | 6 +++++- .../leagues/mana-earned-breakdown.tsx | 1 + web/pages/[username]/[contractSlug].tsx | 12 +++++++++--- 10 files changed, 42 insertions(+), 43 deletions(-) diff --git a/backend/scripts/recalculate-user-contract-metrics.ts b/backend/scripts/recalculate-user-contract-metrics.ts index 97142e890e..f4c776becb 100644 --- a/backend/scripts/recalculate-user-contract-metrics.ts +++ b/backend/scripts/recalculate-user-contract-metrics.ts @@ -41,6 +41,7 @@ if (require.main === module) { const chunks = chunk(allUserIds, chunkSize) let total = 0 for (const userIds of chunks) { + // TODO: before using this, make sure to fix the deprecation warning await updateUserMetricsWithBets(userIds.map((u) => u[0])) total += userIds.length console.log( diff --git a/backend/shared/src/update-user-metrics-with-bets.ts b/backend/shared/src/update-user-metrics-with-bets.ts index 27b21ff755..78bfde5e9b 100644 --- a/backend/shared/src/update-user-metrics-with-bets.ts +++ b/backend/shared/src/update-user-metrics-with-bets.ts @@ -3,7 +3,7 @@ import { createSupabaseDirectClient, SupabaseDirectClient, } from 'shared/supabase/init' -import { getUsers, log } from 'shared/utils' +import { log } from 'shared/utils' import { groupBy, sortBy, sumBy, uniq } from 'lodash' import { Contract, CPMMMultiContract } from 'common/contract' import { calculateMetricsByContractAndAnswer } from 'common/calculate-metrics' @@ -15,7 +15,10 @@ import { getAnswersForContractsDirect } from 'shared/supabase/answers' import { convertBet } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' -// NOTE: This function is just a script and isn't used regularly +/** @deprecated between the time the bets are loaded and the metrics are written, + * the user could place a sell bet that repays a loan, which would not be + * applied to the updated metrics when written. It should check for any sale bets + * and rerun the metrics calculation. **/ export async function updateUserMetricsWithBets( userIds?: string[], since?: number @@ -88,22 +91,18 @@ export async function updateUserMetricsWithBets( const contractMetricUpdates = [] - log('Loading user balances & deposit information...') - // Load user data right before calculating metrics to avoid out-of-date deposit/balance data (esp. for new users that - // get their first 9 deposits upon visiting new markets). - const users = await getUsers(activeUserIds) log('Computing metric updates...') - for (const user of users) { - const userMetricRelevantBets = metricRelevantBets[user.id] ?? [] + for (const userId of activeUserIds) { + const userMetricRelevantBets = metricRelevantBets[userId] ?? [] const metricRelevantBetsByContract = groupBy( userMetricRelevantBets, (b) => b.contractId ) - const currentMetricsForUser = currentMetricsByUserId[user.id] ?? [] + const currentMetricsForUser = currentMetricsByUserId[userId] ?? [] const freshMetrics = calculateMetricsByContractAndAnswer( metricRelevantBetsByContract, contractsById, - user.id, + userId, currentMetricsForUser ) contractMetricUpdates.push( diff --git a/common/src/calculate-metrics.ts b/common/src/calculate-metrics.ts index f0ee05817b..1c28cb8d24 100644 --- a/common/src/calculate-metrics.ts +++ b/common/src/calculate-metrics.ts @@ -177,7 +177,7 @@ export const calculateMetricsByContractAndAnswer = ( return calculateUserMetricsWithouLoans(contract, bets, userId) } ) - // find loan amounts from current metrics and paste them into the new metrics + // Find loan amounts from current metrics and paste them into the new metrics const newMetricsWithLoan = newMetrics.map((m) => { const currentMetric = currentMetrics.find( (cm) => diff --git a/common/src/calculate.ts b/common/src/calculate.ts index 3083fecb8a..e20651d2e7 100644 --- a/common/src/calculate.ts +++ b/common/src/calculate.ts @@ -334,7 +334,7 @@ export const getContractBetMetrics = ( contract: Contract, yourBets: Bet[], answerId?: string -): Omit => { +): Omit => { const { mechanism } = contract const isCpmmMulti = mechanism === 'cpmm-multi-1' const { @@ -346,7 +346,6 @@ export const getContractBetMetrics = ( } = getProfitMetrics(contract, yourBets) const { totalSpent } = calculateTotalSpentAndShares(yourBets) const invested = sum(Object.values(totalSpent)) - const loan = sumBy(yourBets, 'loanAmount') const { totalShares, hasShares, hasYesShares, hasNoShares } = getCpmmShares(yourBets) @@ -357,7 +356,6 @@ export const getContractBetMetrics = ( return { invested, - loan, payout, profit, profitPercent, diff --git a/common/src/redeem.ts b/common/src/redeem.ts index 4a7121a256..9cc7eccfa9 100644 --- a/common/src/redeem.ts +++ b/common/src/redeem.ts @@ -1,27 +1,9 @@ -import { partition, sumBy } from 'lodash' - import { Bet, getNewBetId } from './bet' import { Contract } from './contract' import { noFees } from './fees' import { removeUndefinedProps } from './util/object' import { ContractMetric } from './contract-metric' -type RedeemableBet = Pick - -export const getBinaryRedeemableAmount = (bets: RedeemableBet[]) => { - const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') - const yesShares = sumBy(yesBets, (b) => b.shares) - const noShares = sumBy(noBets, (b) => b.shares) - - const shares = Math.max(Math.min(yesShares, noShares), 0) - const soldFrac = shares > 0 ? shares / Math.max(yesShares, noShares) : 0 - - const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPayment = loanAmount * soldFrac - const netAmount = shares - loanPayment - return { shares, loanPayment, netAmount } -} - export const getBinaryRedeemableAmountFromContractMetric = ( contractMetric: Omit ) => { diff --git a/web/components/bet/contract-bets-table.tsx b/web/components/bet/contract-bets-table.tsx index b6323cab7c..1193a23a0d 100644 --- a/web/components/bet/contract-bets-table.tsx +++ b/web/components/bet/contract-bets-table.tsx @@ -25,16 +25,23 @@ import { Table } from 'web/components/widgets/table' import { formatTimeShort } from 'web/lib/util/time' import { Pagination } from '../widgets/pagination' import { MoneyDisplay } from './money-display' +import { ContractMetric } from 'common/contract-metric' export function ContractBetsTable(props: { contract: Contract bets: Bet[] isYourBets: boolean + contractMetric: ContractMetric hideRedemptionAndLoanMessages?: boolean paginate?: boolean }) { - const { contract, isYourBets, hideRedemptionAndLoanMessages, paginate } = - props + const { + contract, + isYourBets, + hideRedemptionAndLoanMessages, + paginate, + contractMetric, + } = props const { isResolved, mechanism, outcomeType } = contract const bets = sortBy( @@ -51,7 +58,7 @@ export function ContractBetsTable(props: { ) ) - const amountLoaned = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const amountLoaned = contractMetric.loan const isCPMM = mechanism === 'cpmm-1' const isCpmmMulti = mechanism === 'cpmm-multi-1' diff --git a/web/components/bet/user-bets-table.tsx b/web/components/bet/user-bets-table.tsx index 49bfa33861..171114b352 100644 --- a/web/components/bet/user-bets-table.tsx +++ b/web/components/bet/user-bets-table.tsx @@ -748,7 +748,7 @@ function BetsTable(props: { contract={contract} user={user} signedInUser={signedInUser} - contractMetrics={metricsByContractId[contract.id]} + contractMetric={metricsByContractId[contract.id]} areYourBets={areYourBets} /> )} @@ -806,10 +806,10 @@ const ExpandedBetRow = (props: { contract: Contract user: User signedInUser: User | null | undefined - contractMetrics: ContractMetric + contractMetric: ContractMetric areYourBets: boolean }) => { - const { contract, user, signedInUser, contractMetrics, areYourBets } = props + const { contract, user, signedInUser, contractMetric, areYourBets } = props const hideBetsBefore = areYourBets ? 0 : JUNE_1_2022 const bets = useContractBets(contract.id, { userId: user.id, @@ -838,7 +838,7 @@ const ExpandedBetRow = (props: { diff --git a/web/components/contract/contract-page.tsx b/web/components/contract/contract-page.tsx index fffbca514f..cbda447092 100644 --- a/web/components/contract/contract-page.tsx +++ b/web/components/contract/contract-page.tsx @@ -435,7 +435,11 @@ export function ContractPageContent(props: ContractParams) { contract={liveContract} /> )} - + {showReview && user && (
diff --git a/web/components/leagues/mana-earned-breakdown.tsx b/web/components/leagues/mana-earned-breakdown.tsx index c13c016dc9..3be189a05e 100644 --- a/web/components/leagues/mana-earned-breakdown.tsx +++ b/web/components/leagues/mana-earned-breakdown.tsx @@ -231,6 +231,7 @@ const ContractBetsEntry = (props: { contract={contract} bets={bets} isYourBets={false} + contractMetric={metrics} hideRedemptionAndLoanMessages /> )} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 344a5188d9..94e8a12dcc 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -24,6 +24,7 @@ import { initSupabaseAdmin } from 'web/lib/supabase/admin-db' import Custom404 from '../404' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useSweepstakes } from 'web/components/sweepstakes-provider' +import { ContractMetric } from 'common/contract-metric' export async function getStaticProps(ctx: { params: { username: string; contractSlug: string } @@ -144,8 +145,12 @@ function NonPrivateContractPage(props: { contractParams: ContractParams }) { ) } -export function YourTrades(props: { contract: Contract; yourNewBets: Bet[] }) { - const { contract, yourNewBets } = props +export function YourTrades(props: { + contract: Contract + contractMetric: ContractMetric | undefined + yourNewBets: Bet[] +}) { + const { contract, contractMetric, yourNewBets } = props const user = useUser() const staticBets = useBetsOnce({ @@ -188,10 +193,11 @@ export function YourTrades(props: { contract: Contract; yourNewBets: Bet[] }) { /> )} - {visibleUserBets.length > 0 && ( + {visibleUserBets.length > 0 && contractMetric && ( <>
Your trades
Date: Thu, 5 Dec 2024 18:27:39 -0800 Subject: [PATCH 18/21] Fix script --- .../recalculate-user-contract-metrics.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/backend/scripts/recalculate-user-contract-metrics.ts b/backend/scripts/recalculate-user-contract-metrics.ts index f4c776becb..3795e14d32 100644 --- a/backend/scripts/recalculate-user-contract-metrics.ts +++ b/backend/scripts/recalculate-user-contract-metrics.ts @@ -10,6 +10,8 @@ const chunkSize = 10 const FIX_PERIODS = false const UPDATE_PORTFOLIO_HISTORIES = true const MIGRATE_LOAN_DATA = true +const USING_BETS = true +const FIXED_DEPRECATION_WARNING = false if (require.main === module) { runScript(async ({ pg }) => { if (MIGRATE_LOAN_DATA) { @@ -20,10 +22,7 @@ if (require.main === module) { await fixUserPeriods(pg) return } - const allUserIds = [['AJwLWoo3xue32XIiAVrL5SyR1WB2', 0]] as [ - string, - number - ][] + const allUserIds = ['AJwLWoo3xue32XIiAVrL5SyR1WB2'] as string[] // const startTime = new Date(0).toISOString() // const allUserIds = await pg.map( // ` @@ -36,26 +35,30 @@ if (require.main === module) { // [startTime], // (row) => [row.id, row.created_time] // ) - - console.log('Total users:', allUserIds.length) - const chunks = chunk(allUserIds, chunkSize) - let total = 0 - for (const userIds of chunks) { - // TODO: before using this, make sure to fix the deprecation warning - await updateUserMetricsWithBets(userIds.map((u) => u[0])) - total += userIds.length - console.log( - `Updated ${userIds.length} users, total ${total} users updated` - ) - console.log('last created time:', userIds[userIds.length - 1][1]) + if (USING_BETS && FIXED_DEPRECATION_WARNING) { + await recalculateUsingBets(allUserIds) + return } if (UPDATE_PORTFOLIO_HISTORIES) { - await updateUserPortfolioHistoriesCore(allUserIds.map((u) => u[0])) + await updateUserPortfolioHistoriesCore(allUserIds) } }) } +const recalculateUsingBets = async (allUserIds: string[]) => { + console.log('Total users:', allUserIds.length) + const chunks = chunk(allUserIds, chunkSize) + let total = 0 + for (const userIds of chunks) { + // TODO: before using this, make sure to fix the deprecation warning + await updateUserMetricsWithBets(userIds) + total += userIds.length + console.log(`Updated ${userIds.length} users, total ${total} users updated`) + console.log('last created time:', userIds[userIds.length - 1]) + } +} + const fixUserPeriods = async (pg: SupabaseDirectClient) => { // const allUserIds = [['AJwLWoo3xue32XIiAVrL5SyR1WB2', 0]] as [string, number][] const allUserIds = await pg.map( From 519c0e8ee0890024063a9c7291c0553b2dd6adc3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 5 Dec 2024 22:51:23 -0800 Subject: [PATCH 19/21] Merge main --- backend/api/src/get-balance-changes.ts | 37 ++- backend/api/src/league-activity.ts | 5 +- backend/scheduler/src/jobs/index.ts | 2 +- backend/scheduler/src/jobs/update-league.ts | 15 +- backend/shared/src/supabase/utils.ts | 71 +++-- .../shared/src/update-creator-metrics-core.ts | 50 ++-- .../shared/src/update-user-metric-periods.ts | 33 ++- common/src/api/schema.ts | 3 +- common/src/economy.ts | 2 +- web/components/answers/answers-panel.tsx | 2 +- web/components/contract/contract-page.tsx | 2 +- .../notifications/notification-types.tsx | 2 +- .../portfolio/balance-change-table.tsx | 249 +++++++++++------- web/lib/util/time.ts | 4 + web/pages/[username]/index.tsx | 20 +- 15 files changed, 302 insertions(+), 195 deletions(-) diff --git a/backend/api/src/get-balance-changes.ts b/backend/api/src/get-balance-changes.ts index e6dd2f9d87..542f3a8017 100644 --- a/backend/api/src/get-balance-changes.ts +++ b/backend/api/src/get-balance-changes.ts @@ -13,29 +13,36 @@ import { convertContract } from 'common/supabase/contracts' export const getBalanceChanges: APIHandler<'get-balance-changes'> = async ( props ) => { - const { after, userId } = props + const { userId, before, after } = props const [betBalanceChanges, txnBalanceChanges] = await Promise.all([ - getBetBalanceChanges(after, userId), - getTxnBalanceChanges(after, userId), + getBetBalanceChanges(before, after, userId), + getTxnBalanceChanges(before, after, userId), ]) - return orderBy( + const allChanges = orderBy( [...betBalanceChanges, ...txnBalanceChanges], (change) => change.createdTime, 'desc' ) + return allChanges } -const getTxnBalanceChanges = async (after: number, userId: string) => { +const getTxnBalanceChanges = async ( + before: number | undefined, + after: number, + userId: string +) => { const pg = createSupabaseDirectClient() const balanceChanges = [] as TxnBalanceChange[] const txns = await pg.map( `select * from txns - where created_time > millis_to_ts($1) - and (to_id = $2 or from_id = $2) + where + ($1 is null or created_time < millis_to_ts($1)) and + created_time >= millis_to_ts($2) + and (to_id = $3 or from_id = $3) order by created_time`, - [after, userId], + [before, after, userId], convertTxn ) const contractIds = filterDefined( @@ -113,7 +120,11 @@ const getContractIdFromTxn = (txn: Txn) => { return null } -const getBetBalanceChanges = async (after: number, userId: string) => { +const getBetBalanceChanges = async ( + before: number | undefined, + after: number, + userId: string +) => { const pg = createSupabaseDirectClient() const contractToBets: { [contractId: string]: { @@ -133,11 +144,13 @@ const getBetBalanceChanges = async (after: number, userId: string) => { from contract_bets cb join contracts c on cb.contract_id = c.id left join answers a on a.id = cb.answer_id - where cb.updated_time > millis_to_ts($1) - and cb.user_id = $2 + where + ($1 is null or cb.updated_time < millis_to_ts($1)) + and cb.updated_time >= millis_to_ts($2) + and cb.user_id = $3 group by c.id; `, - [after, userId], + [before, after, userId], (row) => { contractToBets[row.id] = { bets: orderBy(row.bets, (bet) => bet.createdTime, 'asc'), diff --git a/backend/api/src/league-activity.ts b/backend/api/src/league-activity.ts index 27ea0e9f0b..beecc907d7 100644 --- a/backend/api/src/league-activity.ts +++ b/backend/api/src/league-activity.ts @@ -83,8 +83,9 @@ export const getLeagueActivity = async ( `select data from contracts where - contracts.id = any($1) - and contracts.visibility = 'public' + id = any($1) + and visibility = 'public' + and token = 'MANA' `, [contractIds], (row) => row.data diff --git a/backend/scheduler/src/jobs/index.ts b/backend/scheduler/src/jobs/index.ts index da63483d45..9ccf90d0ec 100644 --- a/backend/scheduler/src/jobs/index.ts +++ b/backend/scheduler/src/jobs/index.ts @@ -50,7 +50,7 @@ export function createJobs() { ), createJob( 'update-creator-metrics', - `0 */${CREATOR_UPDATE_FREQUENCY} * * * *`, // every 13 minutes - (on the 5th minute of every hour) + `0 */${CREATOR_UPDATE_FREQUENCY} * * * *`, // every 57 minutes - (on the 57th minute of every hour) updateCreatorMetricsCore ), createJob( diff --git a/backend/scheduler/src/jobs/update-league.ts b/backend/scheduler/src/jobs/update-league.ts index f0947ce64b..eea7323e44 100644 --- a/backend/scheduler/src/jobs/update-league.ts +++ b/backend/scheduler/src/jobs/update-league.ts @@ -1,7 +1,6 @@ -import { groupBy, sum, uniq, zipObject } from 'lodash' +import { groupBy, keyBy, sum, uniq, zipObject } from 'lodash' import { log } from 'shared/utils' import { Bet } from 'common/bet' -import { Contract } from 'common/contract' import { SupabaseDirectClient, createSupabaseDirectClient, @@ -9,6 +8,7 @@ import { import { bulkUpdate } from 'shared/supabase/utils' import { CURRENT_SEASON, getSeasonDates } from 'common/leagues' import { getProfitMetrics } from 'common/calculate' +import { convertContract } from 'common/supabase/contracts' export async function updateLeague() { const pg = createSupabaseDirectClient() @@ -71,7 +71,7 @@ export async function updateLeague() { log('Loading contracts...') const contracts = await getRelevantContracts(pg, bets) - const contractsById = Object.fromEntries(contracts.map((c) => [c.id, c])) + const contractsById = keyBy(contracts, 'id') log(`Loaded ${contracts.length} contracts.`) @@ -87,6 +87,7 @@ export async function updateLeague() { const contract = contractsById[contractId] if ( contract && + contract.token === 'MANA' && contract.visibility === 'public' && contract.isRanked !== false && !EXCLUDED_CONTRACT_SLUGS.has(contract.slug) @@ -144,9 +145,13 @@ export async function updateLeague() { const getRelevantContracts = async (pg: SupabaseDirectClient, bets: Bet[]) => { const betContractIds = uniq(bets.map((b) => b.contractId)) return await pg.map( - `select data from contracts where id in ($1:list)`, + `select * from contracts + where id in ($1:list) + and token = 'MANA' + and visibility = 'public' + and (data->'isRanked')::boolean is not false`, [betContractIds], - (r) => r.data as Contract + convertContract ) } diff --git a/backend/shared/src/supabase/utils.ts b/backend/shared/src/supabase/utils.ts index 00c99b349c..f49d7184b1 100644 --- a/backend/shared/src/supabase/utils.ts +++ b/backend/shared/src/supabase/utils.ts @@ -83,10 +83,34 @@ export function bulkUpdateQuery< ColumnValues extends Tables[T]['Update'] >(table: T, idFields: Column[], values: ColumnValues[]) { if (!values.length) return 'select 1 where false' - const columnNames = Object.keys(values[0]) - const cs = new pgp.helpers.ColumnSet(columnNames, { table }) + + // Filter out idFields from the columns to update to avoid pg errors about generated ALWAYS columns + const updateColumns = Object.keys(values[0]).filter( + (col) => !idFields.includes(col as Column) + ) + const allColumns = [...idFields, ...updateColumns] + const cs = new pgp.helpers.ColumnSet(updateColumns, { table }) + + // Format values array to ensure correct column order + const formattedValues = values + .map( + (row) => + `(${allColumns + .map((col) => { + const val = row[col as keyof ColumnValues] + return typeof val === 'string' ? `'${val}'` : val + }) + .join(',')})` + ) + .join(',') + const clause = idFields.map((f) => `v.${f} = t.${f}`).join(' and ') - const query = pgp.helpers.update(values, cs) + ` WHERE ${clause}` + const columnDefs = allColumns.map((c) => `"${c}"`).join(',') + + const query = + `update ${table} as t set ${cs.assignColumns({ from: 'v' })} ` + + `from (values ${formattedValues}) as v(${columnDefs}) ` + + `WHERE ${clause}` // Hack to properly cast values. return query.replace(/::(\w*)'/g, "'::$1") } @@ -144,30 +168,35 @@ export function bulkUpsertQuery< ) } -// Replacement for BulkWriter -export async function bulkUpdateData( - db: SupabaseDirectClient, +export function bulkUpdateDataQuery( table: T, // TODO: explicit id field updates: (Partial> & { id: string | number })[] ) { - if (updates.length > 0) { - const values = updates - .map( - (update) => - `(${ - typeof update.id === 'string' ? `'${update.id}'` : update.id - }, '${JSON.stringify(update)}'::jsonb)` - ) - .join(',\n') + if (updates.length === 0) return 'select 1 where false' - await db.none( - `update ${table} as c - set data = data || v.update - from (values ${values}) as v(id, update) - where c.id = v.id` + const values = updates + .map( + (update) => + `(${ + typeof update.id === 'string' ? `'${update.id}'` : update.id + }, '${JSON.stringify(update)}'::jsonb)` ) - } + .join(',\n') + + return `update ${table} as c + set data = data || v.update + from (values ${values}) as v(id, update) + where c.id = v.id` +} + +export async function bulkUpdateData( + db: SupabaseDirectClient, + table: T, + updates: (Partial> & { id: string | number })[] +) { + const query = bulkUpdateDataQuery(table, updates) + await db.none(query) } export function updateDataQuery( table: T, diff --git a/backend/shared/src/update-creator-metrics-core.ts b/backend/shared/src/update-creator-metrics-core.ts index b6ae215819..318ef96aa6 100644 --- a/backend/shared/src/update-creator-metrics-core.ts +++ b/backend/shared/src/update-creator-metrics-core.ts @@ -4,13 +4,11 @@ import { SupabaseDirectClient, } from 'shared/supabase/init' import { log } from 'shared/utils' -import { User } from 'common/user' import { buildArray } from 'common/util/array' -import { bulkInsert, bulkUpdate } from 'shared/supabase/utils' -import { removeUndefinedProps } from 'common/util/object' +import { bulkInsert, bulkUpdateData } from 'shared/supabase/utils' import { chunk } from 'lodash' -export const CREATOR_UPDATE_FREQUENCY = 13 +export const CREATOR_UPDATE_FREQUENCY = 57 export async function updateCreatorMetricsCore() { const now = Date.now() const yesterday = now - DAY_MS @@ -18,15 +16,14 @@ export async function updateCreatorMetricsCore() { const monthAgo = now - DAY_MS * 30 const pg = createSupabaseDirectClient() log('Loading active creators...') - // TODO: Once we've computed scores for all old market creators, we could focus just on the ones with open markets const allActiveUserIds = await pg.map( ` select contracts.creator_id, latest_cph.ts from ( select distinct creator_id from contracts - where outcome_type != 'POLL' --- and close_time > now() - interval '1 month' + where outcome_type != 'POLL' and outcome_type != 'BOUNTY' + and close_time > now() - interval '1 week' ) contracts left join lateral ( select ts @@ -56,7 +53,7 @@ export async function updateCreatorMetricsCore() { sum((contracts.data->'collectedFees'->'creatorFee')::numeric) as fees_earned from contracts where creator_id in ($1:list) - and contracts.outcome_type != 'POLL' + and contracts.outcome_type != 'POLL' and contracts.outcome_type != 'BOUNTY' group by creator_id `, [activeUserIds], @@ -77,22 +74,15 @@ export async function updateCreatorMetricsCore() { weekAgo, monthAgo ) - const activeUsers = await pg.map( - `select data from users where id in ($1:list)`, - [activeUserIds], - (r) => r.data as User - ) - const userUpdates = activeUsers - .filter((u) => Object.keys(creatorTraders).includes(u.id)) - .map((user) => ({ - ...user, - creatorTraders: { - ...creatorTraders[user.id], - allTime: - creatorPortfolioUpdates.find((c) => c.user_id === user.id) - ?.unique_bettors ?? 0, - }, - })) + const userUpdates = Object.entries(creatorTraders).map(([id, traders]) => ({ + id, + creatorTraders: { + ...traders, + allTime: + creatorPortfolioUpdates.find((c) => c.user_id === id)?.unique_bettors ?? + 0, + }, + })) const chunkSize = 50 const userUpdateChunks = chunk(userUpdates, chunkSize) log('Writing updates and inserts...') @@ -106,15 +96,7 @@ export async function updateCreatorMetricsCore() { ), Promise.all( userUpdateChunks.map(async (chunk) => - bulkUpdate( - pg, - 'users', - ['id'], - chunk.map((u) => ({ - id: u.id, - data: `${JSON.stringify(removeUndefinedProps(u))}::jsonb`, - })) - ) + bulkUpdateData(pg, 'users', chunk) ) ) .catch((e) => log.error('Error bulk writing user updates', e)) @@ -165,7 +147,7 @@ const getCreatorTraders = async ( from contracts as c join contract_traders as ct on c.id = ct.contract_id where c.creator_id in ($1:list) - and c.outcome_type != 'POLL' + and c.outcome_type != 'POLL' and c.outcome_type != 'BOUNTY' group by c.creator_id`, [userIds, new Date(since).toISOString()], (r) => [r.creator_id as string, r.total as number] diff --git a/backend/shared/src/update-user-metric-periods.ts b/backend/shared/src/update-user-metric-periods.ts index bcdbe072b8..9428dd23f6 100644 --- a/backend/shared/src/update-user-metric-periods.ts +++ b/backend/shared/src/update-user-metric-periods.ts @@ -14,8 +14,8 @@ import { filterDefined } from 'common/util/array' import { hasSignificantDeepChanges } from 'common/util/object' import { convertBet } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' +import { bulkUpdateDataQuery, bulkUpdateQuery } from './supabase/utils' import { convertAnswer, convertContract } from 'common/supabase/contracts' -import { bulkUpdateData } from 'shared/supabase/utils' const CHUNK_SIZE = isProd() ? 400 : 10 export async function updateUserMetricPeriods( @@ -145,7 +145,10 @@ export async function updateUserMetricPeriods( (m) => m.userId ) - const contractMetricUpdates: Pick[] = [] + const contractMetricUpdates: Pick< + ContractMetric, + 'from' | 'id' | 'profit' | 'payout' | 'profitPercent' + >[] = [] log('Computing metric updates...') for (const userId of activeUserIds) { @@ -206,15 +209,25 @@ export async function updateUserMetricPeriods( if (contractMetricUpdates.length > 0 && !skipUpdates) { log('Writing updates') - // await bulkUpdateContractMetrics(contractMetricUpdates, pg) - await bulkUpdateData(pg, 'user_contract_metrics', contractMetricUpdates) - .catch((e) => log.error('Error upserting contract metrics', e)) + const updateDataQuery = bulkUpdateDataQuery( + 'user_contract_metrics', + contractMetricUpdates + ) + const updateColumnsQuery = bulkUpdateQuery( + 'user_contract_metrics', + ['id'], + contractMetricUpdates.map((m) => ({ + id: m.id, + profit: m.profit, + })) as any[] + ) + await pg + .multi(`${updateDataQuery}; ${updateColumnsQuery};`) + .catch((e) => log.error('Error updating contract metrics', e)) .then(() => - log( - 'Finished updating ' + - contractMetricUpdates.length + - ' user period metrics.' - ) + log('Finished updating user period metrics.', { + totalUpdates: contractMetricUpdates.length, + }) ) } } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 500d2ad72b..85ac8d42e1 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1342,7 +1342,8 @@ export const API = (_apiTypeCheck = { returns: [] as AnyBalanceChangeType[], props: z .object({ - after: z.coerce.number(), + before: z.coerce.number().optional(), + after: z.coerce.number().default(0), userId: z.string(), }) .strict(), diff --git a/common/src/economy.ts b/common/src/economy.ts index ecd48bfd48..7afc916296 100644 --- a/common/src/economy.ts +++ b/common/src/economy.ts @@ -4,7 +4,7 @@ import { } from 'common/contract' import { MarketTierType, tiers } from './tier' -export const DEFAULT_CASH_ANTE = 100 +export const DEFAULT_CASH_ANTE = 50 export const FIXED_ANTE = 1000 const BASE_ANSWER_COST = FIXED_ANTE / 10 diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index ad49fd746d..a79b201c81 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -254,7 +254,7 @@ export function AnswersPanel(props: { text={query} setText={setQuery} className={clsx( - 'bg-canvas-0 sticky z-[30]', + 'bg-canvas-0 sticky z-10', floatingSearchClassName ?? 'top-[48px]' )} sort={sort} diff --git a/web/components/contract/contract-page.tsx b/web/components/contract/contract-page.tsx index cbda447092..992b249125 100644 --- a/web/components/contract/contract-page.tsx +++ b/web/components/contract/contract-page.tsx @@ -294,7 +294,7 @@ export function ContractPageContent(props: ContractParams) { >
} diff --git a/web/components/portfolio/balance-change-table.tsx b/web/components/portfolio/balance-change-table.tsx index 6a15faec76..7e675557ca 100644 --- a/web/components/portfolio/balance-change-table.tsx +++ b/web/components/portfolio/balance-change-table.tsx @@ -1,3 +1,4 @@ +import dayjs from 'dayjs' import { Col } from 'web/components/layout/col' import { formatMoney, @@ -31,80 +32,161 @@ import { linkClass } from 'web/components/widgets/site-link' import { ScaleIcon } from '@heroicons/react/outline' import { QuestType } from 'common/quest' import { Input } from 'web/components/widgets/input' -import { formatJustTime, formatTimeShort } from 'web/lib/util/time' +import { + formatJustDateShort, + formatJustTime, + formatTimeShort, +} from 'web/lib/util/time' import { assertUnreachable } from 'common/util/types' import { AnyTxnCategory } from 'common/txn' import { useAPIGetter } from 'web/hooks/use-api-getter' import { Button } from 'web/components/buttons/button' import { Modal } from '../layout/modal' -export const BalanceChangeTable = (props: { - user: User - balanceChanges: AnyBalanceChangeType[] - simple?: boolean -}) => { - const { user, simple } = props +export const BalanceChangeTable = (props: { user: User }) => { + const { user } = props + + const [before, setBefore] = useState(undefined) + const [after, setAfter] = useState( + dayjs().startOf('day').subtract(14, 'day').valueOf() + ) + + const { data: allBalanceChanges } = useAPIGetter('get-balance-changes', { + userId: user.id, + before, + after, + }) + const [query, setQuery] = useState('') const { data: cashouts } = useAPIGetter('get-cashouts', { userId: user.id, }) const [showCashoutModal, setShowCashoutModal] = useState(false) - const balanceChanges = props.balanceChanges - .filter((change) => { - const { type } = change - const contractQuestion = - ('contract' in change && change.contract?.question) || '' - const changeType = type - const userName = 'user' in change ? change.user?.name ?? '' : '' - const userUsername = 'user' in change ? change.user?.username ?? '' : '' - const answerText = 'answer' in change ? change.answer?.text ?? '' : '' - const betText = 'bet' in change ? betChangeToText(change) : '' - return ( - contractQuestion.toLowerCase().includes(query.toLowerCase()) || - changeType.toLowerCase().includes(query.toLowerCase()) || - (txnTypeToDescription(changeType) || '') - .toLowerCase() - .includes(query.toLowerCase()) || - answerText.toLowerCase().includes(query.toLowerCase()) || - ((isTxnChange(change) && txnTitle(change)) || '') - .toLowerCase() - .includes(query.toLowerCase()) || - userName.toLowerCase().includes(query.toLowerCase()) || - userUsername.toLowerCase().includes(query.toLowerCase()) || - betText.toLowerCase().includes(query.toLowerCase()) - ) - }) - .slice(0, 1000) + const balanceChanges = (allBalanceChanges ?? []).filter((change) => { + const { type } = change + const contractQuestion = + ('contract' in change && change.contract?.question) || '' + const changeType = type + const userName = 'user' in change ? change.user?.name ?? '' : '' + const userUsername = 'user' in change ? change.user?.username ?? '' : '' + const answerText = 'answer' in change ? change.answer?.text ?? '' : '' + const betText = 'bet' in change ? betChangeToText(change) : '' + return ( + contractQuestion.toLowerCase().includes(query.toLowerCase()) || + changeType.toLowerCase().includes(query.toLowerCase()) || + (txnTypeToDescription(changeType) || '') + .toLowerCase() + .includes(query.toLowerCase()) || + answerText.toLowerCase().includes(query.toLowerCase()) || + ((isTxnChange(change) && txnTitle(change)) || '') + .toLowerCase() + .includes(query.toLowerCase()) || + userName.toLowerCase().includes(query.toLowerCase()) || + userUsername.toLowerCase().includes(query.toLowerCase()) || + betText.toLowerCase().includes(query.toLowerCase()) + ) + }) const pendingCashouts = cashouts?.filter((c) => c.txn.gidxStatus === 'Pending')?.length ?? 0 return ( - + setQuery(e.target.value)} /> - + + + + setAfter(dayjs(e.target.value).startOf('day').valueOf()) + } + /> + to + + setBefore( + e.target.value + ? dayjs(e.target.value).endOf('day').valueOf() + : undefined + ) + } + /> + {cashouts && cashouts.length > 0 && ( - - - + )} - - + + {!!before && before < Date.now() && ( + +
+ Cutoff: {formatJustDateShort(before)} + + + + +
+ + )} + + + +
+ Cutoff: {formatJustDateShort(after)} + + + + + +
+ + @@ -139,10 +221,9 @@ function RenderBalanceChanges(props: { balanceChanges: AnyBalanceChangeType[] user: User avatarSize: 'sm' | 'md' - simple?: boolean hideBalance?: boolean }) { - const { balanceChanges, user, avatarSize, simple, hideBalance } = props + const { balanceChanges, user, avatarSize, hideBalance } = props let currManaBalance = user.balance let currCashBalance = user.cashBalance let currSpiceBalance = user.spiceBalance @@ -178,7 +259,6 @@ function RenderBalanceChanges(props: { change={change} balance={balanceRunningTotals[i]} avatarSize={avatarSize} - simple={simple} hideBalance={hideBalance} token={change.contract.token} /> @@ -190,7 +270,6 @@ function RenderBalanceChanges(props: { change={change as TxnBalanceChange} balance={balanceRunningTotals[i]} avatarSize={avatarSize} - simple={simple} hideBalance={hideBalance} /> ) @@ -261,11 +340,10 @@ const BetBalanceChangeRow = (props: { change: BetBalanceChange balance: { mana: number; cash: number } avatarSize: 'sm' | 'md' - simple?: boolean hideBalance?: boolean token: 'MANA' | 'CASH' }) => { - const { change, balance, avatarSize, simple, hideBalance, token } = props + const { change, balance, avatarSize, hideBalance, token } = props const { amount, contract, answer, bet, type } = change const { outcome } = bet const { slug, question, creatorUsername } = contract @@ -345,19 +423,17 @@ const BetBalanceChangeRow = (props: { {betChangeToText(change)} {answer ? ` on ${answer.text}` : ''} - {!simple && ( - - {!hideBalance && ( - <> - {token === 'CASH' - ? formatSweepies(balance.cash) - : formatMoney(balance.mana)} - {'ยท'} - - )}{' '} - {customFormatTime(change.createdTime)} - - )} + + {!hideBalance && ( + <> + {token === 'CASH' + ? formatSweepies(balance.cash) + : formatMoney(balance.mana)} + {'ยท'} + + )}{' '} + {customFormatTime(change.createdTime)} + ) @@ -374,10 +450,9 @@ const TxnBalanceChangeRow = (props: { change: TxnBalanceChange balance: { mana: number; cash: number; spice: number } avatarSize: 'sm' | 'md' - simple?: boolean hideBalance?: boolean }) => { - const { change, balance, avatarSize, simple, hideBalance } = props + const { change, balance, avatarSize, hideBalance } = props const { contract, amount, type, token, user, charity, description } = change const reasonToBgClassNameMap: Partial<{ @@ -517,21 +592,19 @@ const TxnBalanceChangeRow = (props: {
{txnTypeToDescription(type) ?? description ?? type}
- {!simple && ( - - {!hideBalance && ( - <> - {token === 'SPICE' - ? formatSpice(balance.spice) - : token === 'CASH' - ? formatSweepies(balance.cash) - : formatMoney(balance.mana)} - {' ยท '} - - )} - {customFormatTime(change.createdTime)} - - )} + + {!hideBalance && ( + <> + {token === 'SPICE' + ? formatSpice(balance.spice) + : token === 'CASH' + ? formatSweepies(balance.cash) + : formatMoney(balance.mana)} + {' ยท '} + + )} + {customFormatTime(change.createdTime)} + ) diff --git a/web/lib/util/time.ts b/web/lib/util/time.ts index 5193b51c93..971e8b69b0 100644 --- a/web/lib/util/time.ts +++ b/web/lib/util/time.ts @@ -14,6 +14,10 @@ const FORMATTER = new Intl.DateTimeFormat('default', { export const formatTime = FORMATTER.format +export function formatJustDateShort(time: number) { + return dayjs(time).format('MMM D, YYYY') +} + export function formatTimeShort(time: number) { return dayjs(time).format('MMM D, h:mma') } diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index 0948498728..fb651716bb 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -7,14 +7,12 @@ import { ViewListIcon, } from '@heroicons/react/outline' import clsx from 'clsx' -import { isBetChange } from 'common/balance-change' import { DIVISION_NAMES, getLeaguePath } from 'common/leagues' import { getUserForStaticProps } from 'common/supabase/users' import { isUserLikelySpammer } from 'common/user' import { unauthedApi } from 'common/util/api' import { buildArray } from 'common/util/array' import { removeUndefinedProps } from 'common/util/object' -import dayjs from 'dayjs' import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' @@ -55,7 +53,6 @@ import { linkClass } from 'web/components/widgets/site-link' import { Title } from 'web/components/widgets/title' import { StackedUserNames, UserLink } from 'web/components/widgets/user-link' import { useAdmin } from 'web/hooks/use-admin' -import { useAPIGetter } from 'web/hooks/use-api-getter' import { useFollowers, useFollows } from 'web/hooks/use-follows' import { useHeaderIsStuck } from 'web/hooks/use-header-is-stuck' import { useIsMobile } from 'web/hooks/use-is-mobile' @@ -213,14 +210,8 @@ function UserProfile(props: { } }, [currentUser?.id, user?.id]) - const { data: newBalanceChanges } = useAPIGetter('get-balance-changes', { - userId: user.id, - after: dayjs().startOf('day').subtract(14, 'day').valueOf(), - }) - - const balanceChanges = newBalanceChanges ?? [] - const hasBetBalanceChanges = balanceChanges.some((b) => isBetChange(b)) const balanceChangesKey = 'balance-changes' + return ( ), }, - (!!user.lastBetTime || hasBetBalanceChanges) && { + !!user.lastBetTime && { title: 'Trades', prerender: true, stackedTabIcon: , @@ -454,12 +445,7 @@ function UserProfile(props: { { title: 'Balance log', stackedTabIcon: , - content: ( - - ), + content: , queryString: balanceChangesKey, }, { From 0313a175b483418af49a5c9e43fd980695bc674f Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 6 Dec 2024 08:12:27 -0800 Subject: [PATCH 20/21] Fix native profit column --- .../recalculate-user-contract-metrics.ts | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/backend/scripts/recalculate-user-contract-metrics.ts b/backend/scripts/recalculate-user-contract-metrics.ts index 3795e14d32..f667b75c0b 100644 --- a/backend/scripts/recalculate-user-contract-metrics.ts +++ b/backend/scripts/recalculate-user-contract-metrics.ts @@ -7,13 +7,18 @@ import { updateUserPortfolioHistoriesCore } from 'shared/update-user-portfolio-h import { log } from 'shared/utils' const chunkSize = 10 +const MIGRATE_PROFIT_DATA = true const FIX_PERIODS = false -const UPDATE_PORTFOLIO_HISTORIES = true -const MIGRATE_LOAN_DATA = true -const USING_BETS = true +const UPDATE_PORTFOLIO_HISTORIES = false +const MIGRATE_LOAN_DATA = false +const USING_BETS = false const FIXED_DEPRECATION_WARNING = false if (require.main === module) { runScript(async ({ pg }) => { + if (MIGRATE_PROFIT_DATA) { + await migrateProfitData(pg) + return + } if (MIGRATE_LOAN_DATA) { await migrateLoanData(pg) return @@ -113,3 +118,34 @@ export async function migrateLoanData( log('Finished migrating loan data') } + +// Migrate profit data from data jsonb to native column +export async function migrateProfitData( + pg: SupabaseDirectClient, + chunkSize = 200 +) { + log('Getting all users with contract metrics...') + const userIds = await pg.map( + `select distinct user_id from user_contract_metrics`, + [], + (r) => r.user_id as string + ) + + log(`Found ${userIds.length} users with metrics`) + const chunks = chunk(userIds, chunkSize) + + for (const userChunk of chunks) { + await pg.none( + ` + update user_contract_metrics + set profit = coalesce((data->>'profit')::numeric, 0) + where user_id = any($1) + `, + [userChunk] + ) + + log(`Updated profit data for ${userChunk.length} users`) + } + + log('Finished migrating profit data') +} From a16dc84ead590627d264507c5eb097bc7ffe4133 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 6 Dec 2024 08:14:53 -0800 Subject: [PATCH 21/21] Loan rate to 1.5% --- common/src/loans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/loans.ts b/common/src/loans.ts index 7ed267d17a..582a328f17 100644 --- a/common/src/loans.ts +++ b/common/src/loans.ts @@ -4,7 +4,7 @@ import { PortfolioMetrics } from './portfolio-metrics' import { ContractMetric } from './contract-metric' import { filterDefined } from './util/array' -export const LOAN_DAILY_RATE = 0.04 +export const LOAN_DAILY_RATE = 0.015 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal