From 4338a6f5d08c8806816cef7ab176c5600e458346 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 6 Dec 2024 08:18:23 -0800 Subject: [PATCH] Reenable loans at 1.5% and store on contract metrics (#3188) * Reenable loans and store on contract metrics * Remove unused, add backfill * Share next loan code * Modify return * Add back in safeguards * Fix summary trigger * Table def * Fix cache & loan repayment in place-bet * Remove log * Calculate payouts with contract metrics * Optimize update metrics transaction * Pay back loan in multi-sell * Remove nextLoanCached and fix redemption loan amount * Simplify * Add proper loan numbers to multi-sell panel * Save loan when recalculating metrics * Use contract metric to show loan amount * Fix script * Merge main * Fix native profit column * Loan rate to 1.5% --- backend/api/src/get-next-loan-amount.ts | 13 + backend/api/src/multi-sell.ts | 18 +- backend/api/src/place-bet.ts | 2 +- backend/api/src/redeem-shares.ts | 8 +- backend/api/src/request-loan.ts | 249 ++++++++---------- backend/api/src/routes.ts | 6 +- backend/api/src/unresolve.ts | 18 +- backend/scheduler/src/jobs/update-league.ts | 1 - .../recalculate-multi-contract-metrics.ts | 44 ---- ...lculate-user-contract-metrics-from-bets.ts | 74 ------ .../recalculate-user-contract-metrics.ts | 151 +++++++++++ backend/shared/src/create-notification.ts | 8 +- backend/shared/src/create-user-main.ts | 1 - .../src/helpers/user-contract-metrics.ts | 23 +- backend/shared/src/resolve-market-helpers.ts | 113 ++++---- backend/shared/src/txn/run-txn.ts | 6 +- .../shared/src/update-user-metric-periods.ts | 7 +- .../src/update-user-metrics-with-bets.ts | 22 +- .../update-user-portfolio-histories-core.ts | 39 ++- backend/shared/src/utils.ts | 24 +- backend/shared/src/websockets/helpers.ts | 2 +- backend/supabase/user_contract_metrics.sql | 9 +- common/src/api/schema.ts | 10 + common/src/calculate-metrics.ts | 211 ++++++--------- common/src/calculate.ts | 6 +- common/src/loans.ts | 100 ++++--- common/src/payouts-fixed.ts | 186 +++++++------ common/src/payouts.ts | 50 ++-- common/src/portfolio-metrics.ts | 1 + common/src/redeem.ts | 18 -- common/src/supabase/contracts.ts | 6 +- common/src/supabase/portfolio-metrics.ts | 1 + common/src/user.ts | 1 - web/components/answers/numeric-sell-panel.tsx | 14 +- web/components/bet/contract-bets-table.tsx | 13 +- web/components/bet/user-bets-table.tsx | 9 +- web/components/contract/contract-page.tsx | 6 +- web/components/home/daily-loan.tsx | 13 +- web/components/home/daily-stats.tsx | 2 + .../leagues/mana-earned-breakdown.tsx | 5 +- web/components/profile/loans-modal.tsx | 14 +- web/hooks/use-has-received-loan.ts | 2 +- web/hooks/use-is-eligible-for-loans.ts | 7 +- web/hooks/use-saved-contract-metrics.ts | 20 +- web/next-env.d.ts | 2 +- web/pages/[username]/[contractSlug].tsx | 12 +- 46 files changed, 808 insertions(+), 739 deletions(-) create mode 100644 backend/api/src/get-next-loan-amount.ts delete mode 100644 backend/scripts/recalculate-multi-contract-metrics.ts delete mode 100644 backend/scripts/recalculate-user-contract-metrics-from-bets.ts create mode 100644 backend/scripts/recalculate-user-contract-metrics.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..dddfb4613a --- /dev/null +++ b/backend/api/src/get-next-loan-amount.ts @@ -0,0 +1,13 @@ +import { type APIHandler } from './helpers/endpoint' +import { getNextLoanAmountResults } from 'api/request-loan' + +export const getNextLoanAmount: APIHandler<'get-next-loan-amount'> = async ({ + userId, +}) => { + try { + const { result } = await getNextLoanAmountResults(userId) + return { amount: result.payout } + } catch (e) { + return { amount: 0 } + } +} diff --git a/backend/api/src/multi-sell.ts b/backend/api/src/multi-sell.ts index 44b9459494..52b3f6e79a 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, keyBy, 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,9 +71,13 @@ const multiSellMain: APIHandler<'multi-sell'> = async (props, auth) => { ) const loanAmountByAnswerId = mapValues( - groupBy(userBets, 'answerId'), - (bets) => sumBy(bets, (bet) => bet.loanAmount ?? 0) + keyBy( + allMyMetrics.filter((m) => m.answerId !== null), + 'answerId' + ), + (m) => m.loan ?? 0 ) + const nonRedemptionBetsByAnswerId = groupBy( userBets.filter((bet) => bet.shares !== 0), (bet) => bet.answerId @@ -115,13 +118,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 }) 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/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/api/src/request-loan.ts b/backend/api/src/request-loan.ts index 160b0af2c8..a4234fdc82 100644 --- a/backend/api/src/request-loan.ts +++ b/backend/api/src/request-loan.ts @@ -1,159 +1,90 @@ import { APIError, type APIHandler } from './helpers/endpoint' -import { - createSupabaseDirectClient, - pgp, - SupabaseTransaction, -} from 'shared/supabase/init' +import { createSupabaseDirectClient } 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 { PortfolioMetrics } from 'common/portfolio-metrics' -import { groupBy, uniq } from 'lodash' +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 { 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' - -// 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 { 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) => { - if (LOANS_DIABLED) throw new APIError(500, 'Loans are disabled') +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 - where user_id = $1 - order by ts desc limit 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) { - 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 pg.oneOrNone( - `select data from users where id = $1 limit 1`, - [auth.uid], - (r) => r.data - ) - 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 - ) - log(`Loaded ${contracts.length} contracts.`) - - const contractsById = Object.fromEntries( - contracts.map((contract) => [contract.id, contract]) - ) - const betsByUser = groupBy(bets, (bet) => bet.userId) - - const userContractBets = groupBy( - betsByUser[user.id] ?? [], - (b) => b.contractId + const { result, metricsByContract, user } = await getNextLoanAmountResults( + auth.uid ) - - const result = getUserLoanUpdates(userContractBets, 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` + if (!metric) return undefined + return { + ...metric, + loan: (metric.loan ?? 0) + update.newLoan, + } + }) + ) + 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] ) - - log(`Paid out ${payout} to user ${user.id}.`) - - return { payout } - }) + 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 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', @@ -165,5 +96,49 @@ 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) => { + const pg = createSupabaseDirectClient() + + const portfolioMetric = await pg.oneOrNone( + `select * + from user_portfolio_history_latest + where user_id = $1`, + [userId], + convertPortfolioHistory + ) + 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 } } diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index cafbbc2174..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' @@ -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 } = { @@ -225,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, @@ -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/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/scheduler/src/jobs/update-league.ts b/backend/scheduler/src/jobs/update-league.ts index 876dce134e..eea7323e44 100644 --- a/backend/scheduler/src/jobs/update-league.ts +++ b/backend/scheduler/src/jobs/update-league.ts @@ -1,7 +1,6 @@ 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, 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/scripts/recalculate-user-contract-metrics-from-bets.ts b/backend/scripts/recalculate-user-contract-metrics-from-bets.ts deleted file mode 100644 index 04ab4e6806..0000000000 --- a/backend/scripts/recalculate-user-contract-metrics-from-bets.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { runScript } from './run-script' -import { chunk } from 'lodash' -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' - -const chunkSize = 10 -const FIX_PERIODS = false -const UPDATE_PORTFOLIO_HISTORIES = true -if (require.main === module) { - runScript(async ({ pg }) => { - if (FIX_PERIODS) { - await fixUserPeriods(pg) - return - } - const allUserIds = [['AJwLWoo3xue32XIiAVrL5SyR1WB2', 0]] as [ - string, - number - ][] - // const startTime = new Date(0).toISOString() - // const allUserIds = await pg.map( - // ` - // select distinct users.id, users.created_time from users - // join contract_bets cb on users.id = cb.user_id - // where users.created_time > $1 - // -- and cb.created_time > now () - interval '2 week' - // order by users.created_time - // `, - // [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) { - 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 (UPDATE_PORTFOLIO_HISTORIES) { - await updateUserPortfolioHistoriesCore(allUserIds.map((u) => u[0])) - } - }) -} - -const fixUserPeriods = async (pg: SupabaseDirectClient) => { - // const allUserIds = [['AJwLWoo3xue32XIiAVrL5SyR1WB2', 0]] as [string, number][] - const allUserIds = await pg.map( - ` - select distinct ucm.user_id from user_contract_metrics ucm - join contracts on ucm.contract_id = contracts.id - where resolution_time < now() - interval '7 day' - and (((ucm.data->'from'->'week'->'profit')::numeric) > 0.01 or - ((ucm.data->'from'->'week'->'profit')::numeric) <-0.01); - `, - [], - (row) => [row.user_id, ''] - ) - console.log('Total users:', allUserIds.length) - const chunks = chunk(allUserIds, chunkSize) - let total = 0 - for (const userIds of chunks) { - await updateUserMetricPeriods(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]) - } -} diff --git a/backend/scripts/recalculate-user-contract-metrics.ts b/backend/scripts/recalculate-user-contract-metrics.ts new file mode 100644 index 0000000000..f667b75c0b --- /dev/null +++ b/backend/scripts/recalculate-user-contract-metrics.ts @@ -0,0 +1,151 @@ +import { runScript } from './run-script' +import { chunk } from 'lodash' +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 MIGRATE_PROFIT_DATA = true +const FIX_PERIODS = false +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 + } + if (FIX_PERIODS) { + await fixUserPeriods(pg) + return + } + const allUserIds = ['AJwLWoo3xue32XIiAVrL5SyR1WB2'] as string[] + // const startTime = new Date(0).toISOString() + // const allUserIds = await pg.map( + // ` + // select distinct users.id, users.created_time from users + // join contract_bets cb on users.id = cb.user_id + // where users.created_time > $1 + // -- and cb.created_time > now () - interval '2 week' + // order by users.created_time + // `, + // [startTime], + // (row) => [row.id, row.created_time] + // ) + if (USING_BETS && FIXED_DEPRECATION_WARNING) { + await recalculateUsingBets(allUserIds) + return + } + + if (UPDATE_PORTFOLIO_HISTORIES) { + 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( + ` + select distinct ucm.user_id from user_contract_metrics ucm + join contracts on ucm.contract_id = contracts.id + where resolution_time < now() - interval '7 day' + and (((ucm.data->'from'->'week'->'profit')::numeric) > 0.01 or + ((ucm.data->'from'->'week'->'profit')::numeric) <-0.01); + `, + [], + (row) => [row.user_id, ''] + ) + console.log('Total users:', allUserIds.length) + const chunks = chunk(allUserIds, chunkSize) + let total = 0 + for (const userIds of chunks) { + await updateUserMetricPeriods(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]) + } +} + +// Migrate loan data from data jsonb to native column +export async function migrateLoanData( + 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 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') +} + +// 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') +} 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/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/backend/shared/src/helpers/user-contract-metrics.ts b/backend/shared/src/helpers/user-contract-metrics.ts index 1f3d556d86..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) => @@ -64,6 +46,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-metric-periods.ts b/backend/shared/src/update-user-metric-periods.ts index 2880d3d55a..9428dd23f6 100644 --- a/backend/shared/src/update-user-metric-periods.ts +++ b/backend/shared/src/update-user-metric-periods.ts @@ -158,12 +158,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 diff --git a/backend/shared/src/update-user-metrics-with-bets.ts b/backend/shared/src/update-user-metrics-with-bets.ts index 9e8ba4fccc..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,23 +91,20 @@ 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[userId] ?? [] const freshMetrics = calculateMetricsByContractAndAnswer( metricRelevantBetsByContract, contractsById, - user.id - ).flat() - const currentMetricsForUser = currentMetricsByUserId[user.id] ?? [] + userId, + currentMetricsForUser + ) contractMetricUpdates.push( ...freshMetrics.filter((freshMetric) => { const currentMetric = currentMetricsForUser.find( diff --git a/backend/shared/src/update-user-portfolio-histories-core.ts b/backend/shared/src/update-user-portfolio-histories-core.ts index ce51f8060e..1152ff7f29 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 { + calculateProfitMetricsAtProbOrCancel, + 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, } } @@ -307,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, @@ -316,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/backend/supabase/user_contract_metrics.sql b/backend/supabase/user_contract_metrics.sql index dacbdc3541..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 @@ -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, diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index b79a3999ec..85ac8d42e1 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1883,6 +1883,16 @@ export const API = (_apiTypeCheck = { }) .strict(), }, + 'get-next-loan-amount': { + method: 'GET', + visibility: 'undocumented', + cache: DEFAULT_CACHE_STRATEGY, + authed: false, + returns: {} as { amount: number }, + props: z.object({ + userId: z.string(), + }), + }, } as const) export type APIPath = keyof typeof API diff --git a/common/src/calculate-metrics.ts b/common/src/calculate-metrics.ts index 87bba3329b..1c28cb8d24 100644 --- a/common/src/calculate-metrics.ts +++ b/common/src/calculate-metrics.ts @@ -1,6 +1,7 @@ import { cloneDeep, Dictionary, + first, groupBy, min, orderBy, @@ -9,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' @@ -23,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, @@ -76,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 = ( @@ -213,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 @@ -260,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 @@ -380,10 +322,10 @@ export const calculateUserMetricsWithNewBetsOnly = ( } } -export const calculateProfitMetricsWithProb = < +export const calculateProfitMetricsAtProbOrCancel = < T extends Omit | ContractMetric >( - newProb: number, + newState: number | 'CANCEL', um: T ) => { const { @@ -393,15 +335,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 @@ -546,6 +493,7 @@ export const applyMetricToSummary = < // summaryMetric.hasNoShares // summaryMetric.hasYesShares // summaryMetric.hasShares + // summaryMetric.loan return summary } @@ -556,47 +504,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/calculate.ts b/common/src/calculate.ts index 0c2109dc64..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, @@ -374,7 +372,7 @@ export const getContractBetMetrics = ( contractId: contract.id, } } -export const getContractBetMetricsPerAnswer = ( +export const getContractBetMetricsPerAnswerWithoutLoans = ( contract: Contract, bets: Bet[], answers?: Answer[] diff --git a/common/src/loans.ts b/common/src/loans.ts index 88c285ff37..582a328f17 100644 --- a/common/src/loans.ts +++ b/common/src/loans.ts @@ -1,16 +1,10 @@ -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, 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 +export const LOAN_DAILY_RATE = 0.015 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal @@ -18,73 +12,69 @@ 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) } } export const overLeveraged = (loanTotal: number, investmentValue: number) => loanTotal / investmentValue >= 8 -export const isUserEligibleForLoan = ( - portfolio: (PortfolioMetrics & { userId: string }) | undefined -) => { - if (!portfolio) return true - +export const isUserEligibleForLoan = (portfolio: PortfolioMetrics) => { 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) + } + }) + ) } 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/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/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/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/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/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/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/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 e084481ba4..992b249125 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/home/daily-loan.tsx b/web/components/home/daily-loan.tsx index ab0d87d351..45f4a77484 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,8 @@ 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' +import { DAY_MS } from 'common/util/time' dayjs.extend(utc) dayjs.extend(timezone) @@ -29,7 +29,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,7 +39,9 @@ export function DailyLoan(props: { ) const { receivedLoanToday: receivedTxnLoan, checkTxns } = useHasReceivedLoanToday(user) - const notEligibleForLoan = false //user.nextLoanCached < 1 + const { data } = useAPIGetter('get-next-loan-amount', { userId: user.id }) + const notEligibleForLoan = (data?.amount ?? 0) < 1 + const receivedLoanToday = receivedTxnLoan || justReceivedLoan const getLoan = async () => { @@ -48,7 +50,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 +57,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', { diff --git a/web/components/home/daily-stats.tsx b/web/components/home/daily-stats.tsx index 06a52e4e4c..1008136011 100644 --- a/web/components/home/daily-stats.tsx +++ b/web/components/home/daily-stats.tsx @@ -4,6 +4,7 @@ import { Row } from 'web/components/layout/row' import { QuestsOrStreak } from 'web/components/home/quests-or-streak' import { DailyLeagueStat } from './daily-league-stat' import { DailyProfit } from './daily-profit' +import { DailyLoan } from './daily-loan' export const dailyStatsClass = 'bg-canvas-0 rounded-lg px-3 py-1 shadow min-w-[60px]' @@ -18,6 +19,7 @@ export function DailyStats(props: { + {user && } ) } diff --git a/web/components/leagues/mana-earned-breakdown.tsx b/web/components/leagues/mana-earned-breakdown.tsx index 3f05b0a19a..3be189a05e 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 @@ -231,6 +231,7 @@ const ContractBetsEntry = (props: { contract={contract} bets={bets} isYourBets={false} + contractMetric={metrics} hideRedemptionAndLoanMessages /> )} diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 1364a89e2d..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 @@ -13,8 +14,9 @@ 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) + 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}`) 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/hooks/use-saved-contract-metrics.ts b/web/hooks/use-saved-contract-metrics.ts index aa2b48c90a..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< @@ -54,17 +64,15 @@ 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, }) - return savedMetrics?.find((m) => - answerId ? m.answerId === answerId : m.answerId == null - ) + return savedMetrics } export const useReadLocalContractMetrics = (contractId: string) => { 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. 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