Loans2: Bets return you some capital every week (#588)

* Remove some old loan code

* Almost complete implementation of updateLoans cloud function

* Merge fixes

* Use invested instead of sale value, check if eligible, perform payouts

* Run monday 12am

* Implement loan income notification

* Fix imports

* Loan update fixes / debug

* Handle NaN and negative loan calcs

* Working loan notification

* Loan modal!

* Move loans calculation to /common

* Better layout

* Pay back loan on sell shares

* Pay back fraction of loan on redeem

* Sell bet loan: negate buy bet's loan

* Modal tweaks

* Compute and store nextLoanCached for all users

* lint

* Update loans with newest portfolio

* Filter loans to only unresolved contracts

* Tweak spacing

* Increase memory
This commit is contained in:
James Grugett 2022-08-22 00:22:49 -05:00 committed by GitHub
parent 3158740ea3
commit 8b7cd20b6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 436 additions and 87 deletions

View File

@ -61,5 +61,3 @@ export type fill = {
// I.e. -fill.shares === matchedBet.shares // I.e. -fill.shares === matchedBet.shares
isSale?: boolean isSale?: boolean
} }
export const MAX_LOAN_PER_CONTRACT = 20

138
common/loans.ts Normal file
View File

@ -0,0 +1,138 @@
import { Dictionary, groupBy, sumBy, minBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import {
Contract,
CPMMContract,
FreeResponseContract,
MultipleChoiceContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_WEEKLY_RATE = 0.05
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal
return netValue * LOAN_WEEKLY_RATE
}
export const getLoanUpdates = (
users: User[],
contractsById: { [contractId: string]: Contract },
portfolioByUser: { [userId: string]: PortfolioMetrics | undefined },
betsByUser: { [userId: string]: Bet[] }
) => {
const eligibleUsers = filterDefined(
users.map((user) =>
isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined
)
)
const betUpdates = eligibleUsers
.map((user) => {
const updates = calculateLoanBetUpdates(
betsByUser[user.id] ?? [],
contractsById
).betUpdates
return updates.map((update) => ({ ...update, user }))
})
.flat()
const updatesByUser = groupBy(betUpdates, (update) => update.userId)
const userPayouts = Object.values(updatesByUser).map((updates) => {
return {
user: updates[0].user,
payout: sumBy(updates, (update) => update.newLoan),
}
})
return {
betUpdates,
userPayouts,
}
}
const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => {
if (!portfolio) return true
const { balance, investmentValue } = portfolio
return balance + investmentValue > 0
}
const calculateLoanBetUpdates = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contracts = filterDefined(
Object.keys(betsByContract).map((contractId) => contractsById[contractId])
).filter((c) => !c.isResolved)
const betUpdates = filterDefined(
contracts
.map((c) => {
if (c.mechanism === 'cpmm-1') {
return getBinaryContractLoanUpdate(c, betsByContract[c.id])
} else if (
c.outcomeType === 'FREE_RESPONSE' ||
c.outcomeType === 'MULTIPLE_CHOICE'
)
return getFreeResponseContractLoanUpdate(c, betsByContract[c.id])
else {
// Unsupported contract / mechanism for loans.
return []
}
})
.flat()
)
const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal)
return {
totalNewLoan,
betUpdates,
}
}
const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
const { invested } = getContractBetMetrics(contract, bets)
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const oldestBet = minBy(bets, (bet) => bet.createdTime)
const newLoan = calculateNewLoan(invested, loanAmount)
if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
return {
userId: oldestBet.userId,
contractId: contract.id,
betId: oldestBet.id,
newLoan,
loanTotal,
}
}
const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[]
) => {
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
return openBets.map((bet) => {
const loanAmount = bet.loanAmount ?? 0
const newLoan = calculateNewLoan(bet.amount, loanAmount)
const loanTotal = loanAmount + newLoan
if (isNaN(newLoan) || newLoan <= 0) return undefined
return {
userId: bet.userId,
contractId: contract.id,
betId: bet.id,
newLoan,
loanTotal,
}
})
}

View File

@ -1,6 +1,6 @@
import { sortBy, sum, sumBy } from 'lodash' import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { Bet, fill, LimitBet, NumericBet } from './bet'
import { import {
calculateDpmShares, calculateDpmShares,
getDpmProbability, getDpmProbability,
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
export const getNewBinaryDpmBetInfo = ( export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: DPMBinaryContract, contract: DPMBinaryContract
loanAmount: number
) => { ) => {
const { YES: yesPool, NO: noPool } = contract.pool const { YES: yesPool, NO: noPool } = contract.pool
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
const newBet: CandidateBet = { const newBet: CandidateBet = {
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount, loanAmount: 0,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
const newBet: CandidateBet = { const newBet: CandidateBet = {
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount, loanAmount: 0,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
return { newBet, newPool, newTotalShares, newTotalBets } return { newBet, newPool, newTotalShares, newTotalBets }
} }
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
return loanAmount
}

View File

@ -39,6 +39,7 @@ export type notification_source_types =
| 'bonus' | 'bonus'
| 'challenge' | 'challenge'
| 'betting_streak_bonus' | 'betting_streak_bonus'
| 'loan'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -68,3 +69,4 @@ export type notification_reason_types =
| 'user_joined_from_your_group_invite' | 'user_joined_from_your_group_invite'
| 'challenge_accepted' | 'challenge_accepted'
| 'betting_streak_incremented' | 'betting_streak_incremented'
| 'loan_income'

View File

@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares) const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0) const shares = Math.max(Math.min(yesShares, noShares), 0)
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = Math.min(loanAmount, shares) const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount } return { shares, loanPayment, netAmount }
} }

View File

@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet const { id: betId, amount, shares, outcome, loanAmount } = bet
const adjShareValue = calculateDpmShareValue(contract, bet) const adjShareValue = calculateDpmShareValue(contract, bet)
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
betId, betId,
}, },
fees, fees,
loanAmount: -(loanAmount ?? 0),
} }
return { return {
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
contract: CPMMContract, contract: CPMMContract,
prevLoanAmount: number, unfilledBets: LimitBet[],
unfilledBets: LimitBet[] loanPaid: number
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
unfilledBets unfilledBets
) )
const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -32,6 +32,7 @@ export type User = {
allTime: number allTime: number
} }
nextLoanCached: number
followerCountCached: number followerCountCached: number
followedCategories?: string[] followedCategories?: string[]

View File

@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
} }
transaction.create(newAnswerDoc, answer) transaction.create(newAnswerDoc, answer)
const loanAmount = 0
const { newBet, newPool, newTotalShares, newTotalBets } = const { newBet, newPool, newTotalShares, newTotalBets } =
getNewMultiBetInfo(answerId, amount, contract, loanAmount) getNewMultiBetInfo(answerId, amount, contract)
const newBalance = user.balance - amount const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()

View File

@ -471,6 +471,32 @@ export const createReferralNotification = async (
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
export const createLoanIncomeNotification = async (
toUser: User,
idempotencyKey: string,
income: number
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'loan_income',
createdTime: Date.now(),
isSeen: false,
sourceId: idempotencyKey,
sourceType: 'loan',
sourceUpdateType: 'updated',
sourceUserName: toUser.name,
sourceUserUsername: toUser.username,
sourceUserAvatarUrl: toUser.avatarUrl,
sourceText: income.toString(),
sourceTitle: 'Loan',
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}` const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async ( export const createChallengeAcceptedNotification = async (

View File

@ -75,6 +75,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
createdTime: Date.now(), createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
nextLoanCached: 0,
followerCountCached: 0, followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,

View File

@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './update-metrics' export * from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans'
export * from './backup-db' export * from './backup-db'
export * from './market-close-notifications' export * from './market-close-notifications'
export * from './on-create-answer' export * from './on-create-answer'

View File

@ -59,7 +59,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const user = userSnap.data() as User const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.') if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } = const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract contract
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
@ -119,7 +118,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const answerDoc = contractDoc.collection('answers').doc(outcome) const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc) const answerSnap = await trans.get(answerDoc)
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer') if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
return getNewMultiBetInfo(outcome, amount, contract, loanAmount) return getNewMultiBetInfo(outcome, amount, contract)
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') { } else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
const { outcome, value } = validate(numericSchema, req.body) const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract) return getNumericBetsInfo(value, outcome, amount, contract)

View File

@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const saleAmount = newBet.sale!.amount const saleAmount = newBet.sale!.amount
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0) const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0)
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.update(userDoc, { balance: newBalance }) transaction.update(userDoc, { balance: newBalance })
transaction.update(betDoc, { isSold: true }) transaction.update(betDoc, { isSold: true })
// Note: id should have been newBetDoc.id! But leaving it for now so it's consistent.
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet }) transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
transaction.update( transaction.update(
contractDoc, contractDoc,

View File

@ -7,7 +7,7 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet' import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils' import { log } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { getUnfilledBetsQuery, updateMakers } from './place-bet'
@ -28,12 +28,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBets] = await Promise.all([ const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc), transaction.getAll(contractDoc, userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
]) ])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const user = userSnap.data() as User const user = userSnap.data() as User
@ -45,7 +49,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.') throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) => const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (b) => b.shares) sumBy(bets, (b) => b.shares)
@ -77,18 +81,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
throw new APIError(400, `You can only sell up to ${maxShares} shares.`) throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const soldShares = Math.min(sharesToSell, maxShares) const soldShares = Math.min(sharesToSell, maxShares)
const saleFrac = soldShares / maxShares
const unfilledBetsSnap = await transaction.get( let loanPaid = saleFrac * loanAmount
getUnfilledBetsQuery(contractDoc) if (!isFinite(loanPaid)) loanPaid = 0
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares, soldShares,
chosenOutcome, chosenOutcome,
contract, contract,
prevLoanAmount, unfilledBets,
unfilledBets loanPaid
) )
if ( if (
@ -104,7 +106,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
updateMakers(makers, newBetDoc.id, contractDoc, transaction) updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, { transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount), balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
}) })
transaction.create(newBetDoc, { transaction.create(newBetDoc, {
id: newBetDoc.id, id: newBetDoc.id,

View File

@ -0,0 +1,88 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, keyBy } from 'lodash'
import { getValues, log, payUser, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every Monday.
.pubsub.schedule('0 0 * * 1')
.timeZone('America/Los_Angeles')
.onRun(updateLoansCore)
async function updateLoansCore() {
log('Updating loans...')
const [users, contracts, bets] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
),
getValues<Bet>(firestore.collectionGroup('bets')),
])
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
const userPortfolios = await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
)
log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
const contractsById = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const { betUpdates, userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
log(`${betUpdates.length} bet updates.`)
const betDocUpdates = betUpdates.map((update) => ({
doc: firestore
.collection('contracts')
.doc(update.contractId)
.collection('bets')
.doc(update.betId),
fields: {
loanAmount: update.loanTotal,
},
}))
await writeAsync(firestore, betDocUpdates)
log(`${userPayouts.length} user payouts`)
await Promise.all(
userPayouts.map(({ user, payout }) => payUser(user.id, payout))
)
const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}`
await Promise.all(
userPayouts.map(({ user, payout }) =>
createLoanIncomeNotification(user, key, payout)
)
)
log('Notifications sent!')
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { groupBy, isEmpty, sum, sumBy } from 'lodash' import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate' import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash' import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,7 +22,9 @@ const computeInvestmentValue = (
if (bet.sale || bet.isSold) return 0 if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT') const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0) const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
}) })
} }
@ -71,7 +74,8 @@ export const updateMetricsCore = async () => {
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId) const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId) const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userUpdates = users.map((user) => {
const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? [] const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? []
@ -93,7 +97,29 @@ export const updateMetricsCore = async () => {
newPortfolio, newPortfolio,
didProfitChange didProfitChange
) )
return {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didProfitChange,
}
})
const portfolioByUser = Object.fromEntries(
userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio])
)
const { userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: { fieldUpdates: {
doc: firestore.collection('users').doc(user.id), doc: firestore.collection('users').doc(user.id),
@ -102,6 +128,7 @@ export const updateMetricsCore = async () => {
...(didProfitChange && { ...(didProfitChange && {
profitCached: newProfit, profitCached: newProfit,
}), }),
nextLoanCached,
}, },
}, },
@ -118,7 +145,8 @@ export const updateMetricsCore = async () => {
}, },
}, },
} }
}) }
)
await writeAsync( await writeAsync(
firestore, firestore,
userUpdates.map((u) => u.fieldUpdates) userUpdates.map((u) => u.fieldUpdates)
@ -234,6 +262,6 @@ const calculateNewProfit = (
} }
export const updateMetrics = functions export const updateMetrics = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 }) .runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes') .pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore) .onRun(updateMetricsCore)

View File

@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🔥</span> <span className={'text-8xl'}>🔥</span>
<span>Daily betting streaks</span> <span className="text-xl">Daily betting streaks</span>
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span> <span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

@ -0,0 +1,47 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
export function LoansModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { isOpen, setOpen } = props
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span>
<span className="text-xl">Loans on your bets</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are loans?</span>
<span className={'ml-2'}>
Every Monday, get 5% of your total bet amount back as a loan.
</span>
<span className={'text-indigo-700'}>
Do I have to pay back a loan?
</span>
<span className={'ml-2'}>
Yes, don't worry! You will automatically pay back loans when the
market resolves or you sell your bet.
</span>
<span className={'text-indigo-700'}>
What is the purpose of loans?
</span>
<span className={'ml-2'}>
Loans make it worthwhile to bet on markets that won't resolve for
months or years, because your investment won't be locked up as long.
</span>
<span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}>
For example, if you bet M$100 on "Will I become a millionare?" on
Sunday, you will get M$5 back on Monday.
</span>
<span className={'ml-2'}>
Previous loans count against your total bet amount. So, the next
week, you would get back 5% of M$95 = M$4.75.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -29,6 +29,7 @@ import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button' import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
import { LoansModal } from './profile/loans-modal'
import { REFERRAL_AMOUNT } from 'common/user' import { REFERRAL_AMOUNT } from 'common/user'
export function UserLink(props: { export function UserLink(props: {
@ -68,6 +69,7 @@ export function UserPage(props: { user: User }) {
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => { useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes' const claimedMana = router.query['claimed-mana'] === 'yes'
@ -75,6 +77,9 @@ export function UserPage(props: { user: User }) {
setShowBettingStreakModal(showBettingStreak) setShowBettingStreakModal(showBettingStreak)
setShowConfetti(claimedMana || showBettingStreak) setShowConfetti(claimedMana || showBettingStreak)
const showLoansModel = router.query['show'] === 'loans'
setShowLoansModal(showLoansModel)
const query = { ...router.query } const query = { ...router.query }
if (query.claimedMana || query.show) { if (query.claimedMana || query.show) {
delete query['claimed-mana'] delete query['claimed-mana']
@ -107,6 +112,9 @@ export function UserPage(props: { user: User }) {
isOpen={showBettingStreakModal} isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal} setOpen={setShowBettingStreakModal}
/> />
{showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
)}
{/* Banner image up top, with an circle avatar overlaid */} {/* Banner image up top, with an circle avatar overlaid */}
<div <div
className="h-32 w-full bg-cover bg-center sm:h-40" className="h-32 w-full bg-cover bg-center sm:h-40"
@ -128,7 +136,7 @@ export function UserPage(props: { user: User }) {
<div className="absolute right-0 top-0 mt-2 mr-4"> <div className="absolute right-0 top-0 mt-2 mr-4">
{!isCurrentUser && <UserFollowButton userId={user.id} />} {!isCurrentUser && <UserFollowButton userId={user.id} />}
{isCurrentUser && ( {isCurrentUser && (
<SiteLink className="sm:btn-md btn-sm btn" href="/profile"> <SiteLink className="btn-sm btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '} <PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div> <div className="ml-2">Edit</div>
</SiteLink> </SiteLink>
@ -138,9 +146,14 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */} {/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6"> <Col className="mx-4 -mt-6">
<Row className={'justify-between'}> <Row className={'flex-wrap justify-between gap-y-2'}>
<Col> <Col>
<span className="text-2xl font-bold">{user.name}</span> <span
className="text-2xl font-bold"
style={{ wordBreak: 'break-word' }}
>
{user.name}
</span>
<span className="text-gray-500">@{user.username}</span> <span className="text-gray-500">@{user.username}</span>
</Col> </Col>
<Col className={'justify-center'}> <Col className={'justify-center'}>
@ -160,9 +173,20 @@ export function UserPage(props: { user: User }) {
className={'cursor-pointer items-center text-gray-500'} className={'cursor-pointer items-center text-gray-500'}
onClick={() => setShowBettingStreakModal(true)} onClick={() => setShowBettingStreakModal(true)}
> >
<span>🔥{user.currentBettingStreak ?? 0}</span> <span>🔥 {user.currentBettingStreak ?? 0}</span>
<span>streak</span> <span>streak</span>
</Col> </Col>
<Col
className={
'flex-shrink-0 cursor-pointer items-center text-gray-500'
}
onClick={() => setShowLoansModal(true)}
>
<span className="text-green-600">
🏦 {formatMoney(user.nextLoanCached ?? 0)}
</span>
<span>next loan</span>
</Col>
</Row> </Row>
</Col> </Col>
</Row> </Row>

View File

@ -5,7 +5,7 @@ import {
getNotificationsQuery, getNotificationsQuery,
listenForNotifications, listenForNotifications,
} from 'web/lib/firebase/notifications' } from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash' import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroupsByDay = groupBy(notifications, (notification) => const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString() new Date(notification.createdTime).toDateString()
) )
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
Object.keys(notificationGroupsByDay).forEach((day) => { Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day] const notificationsGroupedByDay = notificationGroupsByDay[day]
const incomeNotifications = notificationsGroupedByDay.filter( const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
notificationsGroupedByDay,
(notification) => (notification) =>
notification.sourceType === 'bonus' || incomeSourceTypes.includes(notification.sourceType ?? '')
notification.sourceType === 'tip' ||
notification.sourceType === 'betting_streak_bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) =>
notification.sourceType !== 'bonus' &&
notification.sourceType !== 'tip' &&
notification.sourceType !== 'betting_streak_bonus'
) )
if (incomeNotifications.length > 0) { if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({ notificationGroups = notificationGroups.concat({

View File

@ -388,6 +388,8 @@ function IncomeNotificationItem(props: {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') { } else if (sourceType === 'betting_streak_bonus') {
reasonText = 'for your' reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as`
} }
const bettingStreakText = const bettingStreakText =
@ -401,7 +403,15 @@ function IncomeNotificationItem(props: {
return ( return (
<> <>
{reasonText} {reasonText}
{sourceType === 'betting_streak_bonus' ? ( {sourceType === 'loan' ? (
simple ? (
<span className={'ml-1 font-bold'}>Loan</span>
) : (
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
Loan
</SiteLink>
)
) : sourceType === 'betting_streak_bonus' ? (
simple ? ( simple ? (
<span className={'ml-1 font-bold'}>{bettingStreakText}</span> <span className={'ml-1 font-bold'}>{bettingStreakText}</span>
) : ( ) : (
@ -445,6 +455,7 @@ function IncomeNotificationItem(props: {
if (sourceType === 'challenge') return `${sourceSlug}` if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus') if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak` return `/${sourceUserUsername}/?show=betting-streak`
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
if (sourceContractCreatorUsername && sourceContractSlug) if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '', sourceId ?? '',