Skip to content

Commit

Permalink
Reenable loans at 1.5% and store on contract metrics (#3188)
Browse files Browse the repository at this point in the history
* 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%
  • Loading branch information
IanPhilips authored Dec 6, 2024
1 parent aa1fa77 commit 4338a6f
Show file tree
Hide file tree
Showing 46 changed files with 808 additions and 739 deletions.
13 changes: 13 additions & 0 deletions backend/api/src/get-next-loan-amount.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
}
18 changes: 7 additions & 11 deletions backend/api/src/multi-sell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
})

Expand Down
2 changes: 1 addition & 1 deletion backend/api/src/place-bet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, maker[]> = {
Expand Down
8 changes: 6 additions & 2 deletions backend/api/src/redeem-shares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
249 changes: 112 additions & 137 deletions backend/api/src/request-loan.ts
Original file line number Diff line number Diff line change
@@ -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<User>(
`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<Bet>(
`
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<Contract>(
`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<LoanTxn, 'fromId' | 'id' | 'createdTime'> = {
const payUserLoan = (userId: string, payout: number) => {
const loanTxn: Omit<LoanTxn, 'id' | 'createdTime'> = {
fromId: 'BANK',
fromType: 'BANK',
toId: userId,
toType: 'USER',
Expand All @@ -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 }
}
6 changes: 4 additions & 2 deletions backend/api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<k> } = {
Expand Down Expand Up @@ -225,7 +226,7 @@ export const handlers: { [k in APIPath]: APIHandler<k> } = {
'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,
Expand Down Expand Up @@ -291,4 +292,5 @@ export const handlers: { [k in APIPath]: APIHandler<k> } = {
'generate-ai-market-suggestions-2': generateAIMarketSuggestions2,
'generate-ai-description': generateAIDescription,
'generate-ai-answers': generateAIAnswers,
'get-next-loan-amount': getNextLoanAmount,
}
Loading

0 comments on commit 4338a6f

Please sign in to comment.