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:
parent
3158740ea3
commit
8b7cd20b6f
|
@ -61,5 +61,3 @@ export type fill = {
|
|||
// I.e. -fill.shares === matchedBet.shares
|
||||
isSale?: boolean
|
||||
}
|
||||
|
||||
export const MAX_LOAN_PER_CONTRACT = 20
|
||||
|
|
138
common/loans.ts
Normal file
138
common/loans.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
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 {
|
||||
calculateDpmShares,
|
||||
getDpmProbability,
|
||||
|
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
|
|||
export const getNewBinaryDpmBetInfo = (
|
||||
outcome: 'YES' | 'NO',
|
||||
amount: number,
|
||||
contract: DPMBinaryContract,
|
||||
loanAmount: number
|
||||
contract: DPMBinaryContract
|
||||
) => {
|
||||
const { YES: yesPool, NO: noPool } = contract.pool
|
||||
|
||||
|
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
loanAmount: 0,
|
||||
shares,
|
||||
outcome,
|
||||
probBefore,
|
||||
|
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
|
|||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
loanAmount: number
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
|
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
|
|||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
loanAmount: 0,
|
||||
shares,
|
||||
outcome,
|
||||
probBefore,
|
||||
|
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ export type notification_source_types =
|
|||
| 'bonus'
|
||||
| 'challenge'
|
||||
| 'betting_streak_bonus'
|
||||
| 'loan'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -68,3 +69,4 @@ export type notification_reason_types =
|
|||
| 'user_joined_from_your_group_invite'
|
||||
| 'challenge_accepted'
|
||||
| 'betting_streak_incremented'
|
||||
| 'loan_income'
|
||||
|
|
|
@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
|||
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 ? Math.min(yesShares, noShares) / shares : 0
|
||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPayment = Math.min(loanAmount, shares)
|
||||
const loanPayment = loanAmount * soldFrac
|
||||
const netAmount = shares - loanPayment
|
||||
return { shares, loanPayment, netAmount }
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
|||
|
||||
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||
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)
|
||||
|
||||
|
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
|||
betId,
|
||||
},
|
||||
fees,
|
||||
loanAmount: -(loanAmount ?? 0),
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
|
|||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
contract: CPMMContract,
|
||||
prevLoanAmount: number,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
loanPaid: number
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
|
||||
|
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
|
|||
unfilledBets
|
||||
)
|
||||
|
||||
const loanPaid = Math.min(prevLoanAmount, saleValue)
|
||||
const probBefore = getCpmmProbability(pool, p)
|
||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ export type User = {
|
|||
allTime: number
|
||||
}
|
||||
|
||||
nextLoanCached: number
|
||||
followerCountCached: number
|
||||
|
||||
followedCategories?: string[]
|
||||
|
|
|
@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
|||
}
|
||||
transaction.create(newAnswerDoc, answer)
|
||||
|
||||
const loanAmount = 0
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets } =
|
||||
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
|
||||
getNewMultiBetInfo(answerId, amount, contract)
|
||||
|
||||
const newBalance = user.balance - amount
|
||||
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||
|
|
|
@ -471,6 +471,32 @@ export const createReferralNotification = async (
|
|||
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}`
|
||||
|
||||
export const createChallengeAcceptedNotification = async (
|
||||
|
|
|
@ -75,6 +75,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
createdTime: Date.now(),
|
||||
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
nextLoanCached: 0,
|
||||
followerCountCached: 0,
|
||||
followedCategories: DEFAULT_CATEGORIES,
|
||||
shouldShowWelcome: true,
|
||||
|
|
|
@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
|
|||
export * from './on-view'
|
||||
export * from './update-metrics'
|
||||
export * from './update-stats'
|
||||
export * from './update-loans'
|
||||
export * from './backup-db'
|
||||
export * from './market-close-notifications'
|
||||
export * from './on-create-answer'
|
||||
|
|
|
@ -59,7 +59,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
const user = userSnap.data() as User
|
||||
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
||||
|
||||
const loanAmount = 0
|
||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||
contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
|
@ -119,7 +118,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||
const answerSnap = await trans.get(answerDoc)
|
||||
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') {
|
||||
const { outcome, value } = validate(numericSchema, req.body)
|
||||
return getNumericBetsInfo(value, outcome, amount, contract)
|
||||
|
|
|
@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
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()
|
||||
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
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.update(
|
||||
contractDoc,
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
|||
import { User } from '../../common/user'
|
||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
import { getValues, log } from './utils'
|
||||
import { log } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
|
@ -28,12 +28,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${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),
|
||||
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 (!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 user = userSnap.data() as User
|
||||
|
@ -45,7 +49,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
if (closeTime && Date.now() > closeTime)
|
||||
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 sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||
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.`)
|
||||
|
||||
const soldShares = Math.min(sharesToSell, maxShares)
|
||||
|
||||
const unfilledBetsSnap = await transaction.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
)
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
const saleFrac = soldShares / maxShares
|
||||
let loanPaid = saleFrac * loanAmount
|
||||
if (!isFinite(loanPaid)) loanPaid = 0
|
||||
|
||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
prevLoanAmount,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
loanPaid
|
||||
)
|
||||
|
||||
if (
|
||||
|
@ -104,7 +106,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
|
||||
|
||||
transaction.update(userDoc, {
|
||||
balance: FieldValue.increment(-newBet.amount),
|
||||
balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
|
||||
})
|
||||
transaction.create(newBetDoc, {
|
||||
id: newBetDoc.id,
|
||||
|
|
88
functions/src/update-loans.ts
Normal file
88
functions/src/update-loans.ts
Normal 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!')
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
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 { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
|
|||
import { calculatePayout } from '../../common/calculate'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { last } from 'lodash'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -21,7 +22,9 @@ const computeInvestmentValue = (
|
|||
if (bet.sale || bet.isSold) return 0
|
||||
|
||||
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 betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||
const userUpdates = users.map((user) => {
|
||||
|
||||
const userMetrics = users.map((user) => {
|
||||
const currentBets = betsByUser[user.id] ?? []
|
||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||
const userContracts = contractsByUser[user.id] ?? []
|
||||
|
@ -93,7 +97,29 @@ export const updateMetricsCore = async () => {
|
|||
newPortfolio,
|
||||
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 {
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
|
@ -102,6 +128,7 @@ export const updateMetricsCore = async () => {
|
|||
...(didProfitChange && {
|
||||
profitCached: newProfit,
|
||||
}),
|
||||
nextLoanCached,
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -118,7 +145,8 @@ export const updateMetricsCore = async () => {
|
|||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates.map((u) => u.fieldUpdates)
|
||||
|
@ -234,6 +262,6 @@ const calculateNewProfit = (
|
|||
}
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
|
|
|
@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
|
|||
<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>Daily betting streaks</span>
|
||||
<span className="text-xl">Daily betting streaks</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are they?</span>
|
||||
<span className={'ml-2'}>
|
||||
|
|
47
web/components/profile/loans-modal.tsx
Normal file
47
web/components/profile/loans-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -29,6 +29,7 @@ import { formatMoney } from 'common/util/format'
|
|||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||
import { LoansModal } from './profile/loans-modal'
|
||||
import { REFERRAL_AMOUNT } from 'common/user'
|
||||
|
||||
export function UserLink(props: {
|
||||
|
@ -68,6 +69,7 @@ export function UserPage(props: { user: User }) {
|
|||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
|
@ -75,6 +77,9 @@ export function UserPage(props: { user: User }) {
|
|||
setShowBettingStreakModal(showBettingStreak)
|
||||
setShowConfetti(claimedMana || showBettingStreak)
|
||||
|
||||
const showLoansModel = router.query['show'] === 'loans'
|
||||
setShowLoansModal(showLoansModel)
|
||||
|
||||
const query = { ...router.query }
|
||||
if (query.claimedMana || query.show) {
|
||||
delete query['claimed-mana']
|
||||
|
@ -107,6 +112,9 @@ export function UserPage(props: { user: User }) {
|
|||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
/>
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
)}
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
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">
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
{isCurrentUser && (
|
||||
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
||||
<SiteLink className="btn-sm btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
|
@ -138,9 +146,14 @@ export function UserPage(props: { user: User }) {
|
|||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<Row className={'justify-between'}>
|
||||
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||
<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>
|
||||
</Col>
|
||||
<Col className={'justify-center'}>
|
||||
|
@ -163,6 +176,17 @@ export function UserPage(props: { user: User }) {
|
|||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||
<span>streak</span>
|
||||
</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>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
getNotificationsQuery,
|
||||
listenForNotifications,
|
||||
} from 'web/lib/firebase/notifications'
|
||||
import { groupBy, map } from 'lodash'
|
||||
import { groupBy, map, partition } from 'lodash'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
|
||||
|
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
||||
new Date(notification.createdTime).toDateString()
|
||||
)
|
||||
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
|
||||
|
||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
||||
const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
|
||||
notificationsGroupedByDay,
|
||||
(notification) =>
|
||||
notification.sourceType === 'bonus' ||
|
||||
notification.sourceType === 'tip' ||
|
||||
notification.sourceType === 'betting_streak_bonus'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType !== 'bonus' &&
|
||||
notification.sourceType !== 'tip' &&
|
||||
notification.sourceType !== 'betting_streak_bonus'
|
||||
incomeSourceTypes.includes(notification.sourceType ?? '')
|
||||
)
|
||||
if (incomeNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
|
|
|
@ -388,6 +388,8 @@ function IncomeNotificationItem(props: {
|
|||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
} else if (sourceType === 'betting_streak_bonus') {
|
||||
reasonText = 'for your'
|
||||
} else if (sourceType === 'loan' && sourceText) {
|
||||
reasonText = `of your invested bets returned as`
|
||||
}
|
||||
|
||||
const bettingStreakText =
|
||||
|
@ -401,7 +403,15 @@ function IncomeNotificationItem(props: {
|
|||
return (
|
||||
<>
|
||||
{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 ? (
|
||||
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||
) : (
|
||||
|
@ -445,6 +455,7 @@ function IncomeNotificationItem(props: {
|
|||
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||
if (sourceType === 'betting_streak_bonus')
|
||||
return `/${sourceUserUsername}/?show=betting-streak`
|
||||
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
sourceId ?? '',
|
||||
|
|
Loading…
Reference in New Issue
Block a user