diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts
index b27ac977..7c2153c1 100644
--- a/common/calculate-metrics.ts
+++ b/common/calculate-metrics.ts
@@ -21,6 +21,25 @@ const computeInvestmentValue = (
})
}
+export const computeInvestmentValueCustomProb = (
+ bets: Bet[],
+ contract: Contract,
+ p: number
+) => {
+ return sumBy(bets, (bet) => {
+ if (!contract || contract.isResolved) return 0
+ if (bet.sale || bet.isSold) return 0
+ const { outcome, shares } = bet
+
+ const betP = outcome === 'YES' ? p : 1 - p
+
+ const payout = betP * shares
+ const value = payout - (bet.loanAmount ?? 0)
+ if (isNaN(value)) return 0
+ return value
+ })
+}
+
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
diff --git a/common/user.ts b/common/user.ts
index 0372d99b..b1365929 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -57,6 +57,7 @@ export type PrivateUser = {
email?: string
weeklyTrendingEmailSent?: boolean
+ weeklyPortfolioUpdateEmailSent?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string
diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html
new file mode 100644
index 00000000..909c1a92
--- /dev/null
+++ b/functions/src/email-templates/weekly-portfolio-update.html
@@ -0,0 +1,490 @@
+
+
+
+
+ State of your predictions on Manifold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ We ran the numbers and here's how you did this past week!
+
+
+
+ |
+
+
+
+
+
+
+
+
+ Investment value
+ |
+
+ Balance
+ |
+
+ Tips
+ |
+
+ Markets
+ |
+
+ Traders
+ |
+
+
+
+
+
+
+
+ {{investment_value}}
+
+
+ {{investment_change}}
+
+
+
+ |
+
+ {{current_balance}}
+ |
+
+ {{tips_received}}
+ |
+
+ {{markets_created}}
+ |
+
+ {{unique_bettors}}
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+ And here's some of the biggest changes in your portfolio:
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index 98309ebe..c7d286cc 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
-import { getUser } from './utils'
+import { contractUrl, getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
@@ -20,6 +20,10 @@ import {
getNotificationDestinationsForUser,
notification_preference,
} from '../../common/user-notification-preferences'
+import {
+ PerContractInvestmentsData,
+ OverallPerformanceData,
+} from './weekly-profit-loss-emails'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@@ -507,10 +511,6 @@ export const sendInterestingMarketsEmail = async (
)
}
-function contractUrl(contract: Contract) {
- return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
-}
-
function imageSourceUrl(contract: Contract) {
return buildCardUrl(getOpenGraphProps(contract))
}
@@ -612,3 +612,42 @@ export const sendNewUniqueBettorsEmail = async (
}
)
}
+
+export const sendWeeklyPortfolioUpdateEmail = async (
+ user: User,
+ privateUser: PrivateUser,
+ investments: PerContractInvestmentsData[],
+ overallPerformance: OverallPerformanceData
+) => {
+ if (
+ !privateUser ||
+ !privateUser.email ||
+ !privateUser.notificationPreferences.profit_loss_updates.includes('email')
+ )
+ return
+
+ const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
+ 'profit_loss_updates' as notification_preference
+ }`
+
+ const { name } = user
+ const firstName = name.split(' ')[0]
+ const templateData: Record = {
+ name: firstName,
+ unsubscribeUrl,
+ ...overallPerformance,
+ }
+ investments.forEach((investment, i) => {
+ templateData[`question${i + 1}Title`] = investment.questionTitle
+ templateData[`question${i + 1}Url`] = investment.questionUrl
+ templateData[`question${i + 1}Prob`] = investment.questionProb
+ templateData[`question${i + 1}Change`] = investment.questionChange
+ })
+
+ await sendTemplateEmail(
+ privateUser.email,
+ `Here's your weekly portfolio update!`,
+ 'portfolio-update',
+ templateData
+ )
+}
diff --git a/functions/src/index.ts b/functions/src/index.ts
index adfee75e..47b1aaef 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -30,6 +30,7 @@ export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow'
export * from './on-update-like'
+export * from './weekly-profit-loss-emails'
// v2
export * from './health'
@@ -50,6 +51,7 @@ export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
+export * from './test-scheduled-function'
import { health } from './health'
import { transact } from './transact'
@@ -72,6 +74,7 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
+import { testscheduledfunction } from './test-scheduled-function'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@@ -98,6 +101,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
+const testScheduledFunction = toCloudFunction(testscheduledfunction)
export {
healthFunction as health,
@@ -121,5 +125,6 @@ export {
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
createPostFunction as createpost,
- saveTwitchCredentials as savetwitchcredentials
+ saveTwitchCredentials as savetwitchcredentials,
+ testScheduledFunction as testscheduledfunction,
}
diff --git a/functions/src/serve.ts b/functions/src/serve.ts
index 6d062d40..99ac6281 100644
--- a/functions/src/serve.ts
+++ b/functions/src/serve.ts
@@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
+import { testscheduledfunction } from './test-scheduled-function'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)
+addEndpointRoute('/testscheduledfunction', testscheduledfunction)
app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`)
diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts
new file mode 100644
index 00000000..6b64e0cd
--- /dev/null
+++ b/functions/src/test-scheduled-function.ts
@@ -0,0 +1,16 @@
+import { APIError, newEndpoint } from './api'
+import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-profit-loss-emails'
+import { isProd } from './utils'
+
+export const testscheduledfunction = newEndpoint(
+ { method: 'GET', memory: '4GiB' },
+ async (_req) => {
+ // Replace your function here
+ if (isProd())
+ throw new APIError(400, 'This function is only available in dev mode')
+
+ await sendPortfolioUpdateEmailsToAllUsers()
+
+ return { success: true }
+ }
+)
diff --git a/functions/src/utils.ts b/functions/src/utils.ts
index 6bb8349a..efc22e53 100644
--- a/functions/src/utils.ts
+++ b/functions/src/utils.ts
@@ -170,3 +170,7 @@ export const chargeUser = (
export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}`
}
+
+export function contractUrl(contract: Contract) {
+ return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
+}
diff --git a/functions/src/weekly-profit-loss-emails.ts b/functions/src/weekly-profit-loss-emails.ts
new file mode 100644
index 00000000..3721cd12
--- /dev/null
+++ b/functions/src/weekly-profit-loss-emails.ts
@@ -0,0 +1,276 @@
+import * as functions from 'firebase-functions'
+import * as admin from 'firebase-admin'
+
+import { Contract, CPMMContract } from '../../common/contract'
+import {
+ getAllPrivateUsers,
+ getPrivateUser,
+ getUser,
+ getValue,
+ getValues,
+ isProd,
+ log,
+} from './utils'
+import { filterDefined } from '../../common/util/array'
+import { PortfolioMetrics } from '../../common/user'
+import { DAY_MS } from '../../common/util/time'
+import { last, partition, sortBy, sum, uniq } from 'lodash'
+import { Bet } from '../../common/bet'
+import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics'
+import { sendWeeklyPortfolioUpdateEmail } from './emails'
+import { contractUrl } from './utils'
+import { Txn } from '../../common/txn'
+import { formatMoney } from '../../common/util/format'
+
+// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week
+export const weeklyPortfolioUpdateEmails = functions
+ .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
+ // every minute on Wednesday for an hour at 12pm PT (UTC -07:00)
+ .pubsub.schedule('* 19 * * 3')
+ .timeZone('Etc/UTC')
+ .onRun(async () => {
+ await sendPortfolioUpdateEmailsToAllUsers()
+ })
+
+const firestore = admin.firestore()
+
+export async function sendPortfolioUpdateEmailsToAllUsers() {
+ const privateUsers = isProd()
+ ? await getAllPrivateUsers()
+ : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
+ // get all users that haven't unsubscribed from weekly emails
+ const privateUsersToSendEmailsTo = privateUsers
+ .filter((user) => {
+ return isProd()
+ ? user.notificationPreferences.profit_loss_updates.includes('email') &&
+ !user.weeklyPortfolioUpdateEmailSent
+ : true
+ })
+ // Send emails in batches
+ .slice(0, 200)
+ log(
+ 'Sending weekly portfolio emails to',
+ privateUsersToSendEmailsTo.length,
+ 'users'
+ )
+
+ const usersToPortfolioMetrics: { [userId: string]: PortfolioMetrics[] } = {}
+ // get all portfolio metrics for each user
+ await Promise.all(
+ privateUsersToSendEmailsTo.map(async (user) => {
+ return getValues(
+ firestore.collection(`users/${user.id}/portfolioHistory`)
+ ).then((portfolioMetrics) => {
+ return (usersToPortfolioMetrics[user.id] = portfolioMetrics)
+ })
+ })
+ )
+
+ const usersBets: { [userId: string]: Bet[] } = {}
+ // get all bets made by each user
+ await Promise.all(
+ privateUsersToSendEmailsTo.map(async (user) => {
+ return getValues(
+ firestore.collectionGroup('bets').where('userId', '==', user.id)
+ ).then((bets) => {
+ usersBets[user.id] = bets
+ })
+ })
+ )
+
+ const usersToContractsCreated: { [userId: string]: Contract[] } = {}
+ // Get all contracts created by each user
+ await Promise.all(
+ privateUsersToSendEmailsTo.map(async (user) => {
+ return getValues(
+ firestore
+ .collection('contracts')
+ .where('creatorId', '==', user.id)
+ .where('createdTime', '>', Date.now() - 7 * DAY_MS)
+ ).then((contracts) => {
+ usersToContractsCreated[user.id] = contracts
+ })
+ })
+ )
+
+ // Get all txns the users received over the past week
+ const usersToTxnsReceived: { [userId: string]: Txn[] } = {}
+ await Promise.all(
+ privateUsersToSendEmailsTo.map(async (user) => {
+ return getValues(
+ firestore
+ .collection(`txns`)
+ .where('toId', '==', user.id)
+ .where('createdTime', '>', Date.now() - 7 * DAY_MS)
+ ).then((txn) => {
+ usersToTxnsReceived[user.id] = txn
+ })
+ })
+ )
+
+ // Get a flat map of all the bets that users made to get the contracts they bet on
+ const contractsUsersBetOn = filterDefined(
+ await Promise.all(
+ uniq(
+ Object.values(usersBets).flatMap((bets) =>
+ bets.map((bet) => bet.contractId)
+ )
+ ).map((contractId) =>
+ getValue(firestore.collection('contracts').doc(contractId))
+ )
+ )
+ )
+ log('Found', contractsUsersBetOn.length, 'contracts')
+
+ await Promise.all(
+ privateUsersToSendEmailsTo.map(async (privateUser) => {
+ const user = await getUser(privateUser.id)
+ if (!user) return
+ const usersPortfolioMetrics = usersToPortfolioMetrics[privateUser.id]
+ const userBets = usersBets[privateUser.id] as Bet[]
+ const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
+ userBets.some((bet) => bet.contractId === contract.id)
+ )
+ // get the most recent bet for each contract
+ // get the most recent portfolio metrics
+ const mostRecentPortfolioMetrics = last(
+ sortBy(
+ usersPortfolioMetrics,
+ (portfolioMetric) => portfolioMetric.timestamp
+ )
+ )
+ if (!mostRecentPortfolioMetrics) {
+ log('No portfolio metrics for user', privateUser.id)
+ return
+ }
+ // get the portfolio metrics from a week ago
+ const portfolioMetricsAWeekAgo = usersPortfolioMetrics.find(
+ (portfolioMetric) => portfolioMetric.timestamp > Date.now() - 7 * DAY_MS
+ )
+ if (!portfolioMetricsAWeekAgo) {
+ // TODO: send them a no change email?
+ log('No portfolio metrics a week ago for user', privateUser.id)
+ return
+ }
+ // get the difference
+ const performanceData = {
+ investment_value: formatMoney(
+ mostRecentPortfolioMetrics.investmentValue
+ ),
+ investment_change: formatMoney(
+ portfolioMetricsAWeekAgo.investmentValue -
+ mostRecentPortfolioMetrics.investmentValue
+ ),
+ current_balance: formatMoney(user.balance),
+ markets_created:
+ usersToContractsCreated[privateUser.id].length.toString(),
+ tips_received: formatMoney(
+ sum(
+ usersToTxnsReceived[privateUser.id]
+ .filter((txn) => txn.category === 'TIP')
+ .map((txn) => txn.amount)
+ )
+ ),
+ unique_bettors: usersToTxnsReceived[privateUser.id]
+ .filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
+ .length.toString(),
+ // More options: bonuses, tips given,
+ } as OverallPerformanceData
+ type investmentDiff = {
+ currentValue: number
+ pastValue: number
+ // contract: Contract
+ difference: number
+ }
+ // calculate the differences of their bets' probAfter to the current markets probabilities
+ const investmentValueDifferences = sortBy(
+ filterDefined(
+ contractsUserBetOn.map((contract) => {
+ const cpmmContract = contract as CPMMContract
+ if (cpmmContract === undefined || cpmmContract.prob === undefined)
+ return
+ const bets = userBets.filter(
+ (bet) => bet.contractId === contract.id
+ )
+
+ const marketProbabilityAWeekAgo =
+ cpmmContract.prob - cpmmContract.probChanges.week
+ const currentMarketProbability = cpmmContract.prob
+ const betsValueAWeekAgo = computeInvestmentValueCustomProb(
+ bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS),
+ contract,
+ marketProbabilityAWeekAgo
+ )
+ const currentBetsValue = computeInvestmentValueCustomProb(
+ bets,
+ contract,
+ currentMarketProbability
+ )
+ return {
+ currentValue: currentBetsValue,
+ pastValue: betsValueAWeekAgo,
+ difference: currentBetsValue - betsValueAWeekAgo,
+ contractSlug: contract.slug,
+ marketProbAWeekAgo: marketProbabilityAWeekAgo,
+ questionTitle: contract.question,
+ questionUrl: contractUrl(contract),
+ questionProb: Math.round(cpmmContract.prob * 100) + '%',
+ questionChange:
+ Math.round(
+ (currentMarketProbability - marketProbabilityAWeekAgo) * 100
+ ) + '%',
+ }
+ })
+ ),
+ (differences) => Math.abs(differences.difference)
+ ).reverse()
+ log(
+ 'Found',
+ investmentValueDifferences.length,
+ 'investment differences for user',
+ privateUser.id
+ )
+
+ const [winningInvestments, losingInvestments] = partition(
+ investmentValueDifferences.filter(
+ (diff) =>
+ diff.pastValue > 0.01 &&
+ Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1%
+ ),
+ (investmentDiff: investmentDiff) => {
+ return investmentDiff.difference > 0
+ }
+ )
+ // pick 3 winning investments and 3 losing investments
+ const topInvestments = winningInvestments.slice(0, 2)
+ const worstInvestments = losingInvestments.slice(0, 2)
+ // console.log('winningInvestments', topInvestments)
+ console.log('losingInvestments', worstInvestments)
+ log('perf data:', performanceData)
+ await sendWeeklyPortfolioUpdateEmail(
+ user,
+ privateUser,
+ topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
+ performanceData
+ )
+ return firestore.collection('private-users').doc(privateUser.id).update({
+ weeklyPortfolioUpdateEmailSent: true,
+ })
+ })
+ )
+}
+
+export type PerContractInvestmentsData = {
+ questionTitle: string
+ questionUrl: string
+ questionProb: string
+ questionChange: string
+}
+export type OverallPerformanceData = {
+ investment_value: string
+ investment_change: string
+ current_balance: string
+ tips_received: string
+ markets_created: string
+ unique_bettors: string
+}