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-no-movers.html b/functions/src/email-templates/weekly-portfolio-update-no-movers.html
new file mode 100644
index 00000000..15303992
--- /dev/null
+++ b/functions/src/email-templates/weekly-portfolio-update-no-movers.html
@@ -0,0 +1,411 @@
+
+
+
+
+ Weekly Portfolio Update on Manifold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ We ran the numbers and here's how you did this past week!
+
+
+
+ |
+
+
+
+
+
+ Profit
+ |
+
+
+
+
+ {{profit}}
+
+ |
+
+
+
+
+
+
+ 🔥 Prediction streak
+ |
+
+ {{prediction_streak}}
+ |
+
+
+
+ 💸 Tips received
+ |
+
+ {{tips_received}}
+ |
+
+
+
+ 📈 Markets traded
+ |
+
+ {{markets_traded}}
+ |
+
+
+
+ ❓ Markets created
+ |
+
+
+ {{markets_created}}
+ |
+
+
+
+ 🥳 Traders attracted
+ |
+
+ {{unique_bettors}}
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
\ No newline at end of file
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..fd99837f
--- /dev/null
+++ b/functions/src/email-templates/weekly-portfolio-update.html
@@ -0,0 +1,510 @@
+
+
+
+
+ Weekly Portfolio Update on Manifold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ We ran the numbers and here's how you did this past week!
+
+
+
+ |
+
+
+
+
+
+ Profit
+ |
+
+
+
+
+ {{profit}}
+
+ |
+
+
+
+
+
+
+ 🔥 Prediction streak
+ |
+
+ {{prediction_streak}}
+ |
+
+
+
+ 💸 Tips received
+ |
+
+ {{tips_received}}
+ |
+
+
+
+ 📈 Markets traded
+ |
+
+ {{markets_traded}}
+ |
+
+
+
+ ❓ Markets created
+ |
+
+
+ {{markets_created}}
+ |
+
+
+
+ 🥳 Traders attracted
+ |
+
+ {{unique_bettors}}
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ And here's some of the biggest changes in your portfolio:
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index 98309ebe..dd91789a 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -12,14 +12,15 @@ 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'
+import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import {
- getNotificationDestinationsForUser,
- notification_preference,
-} from '../../common/user-notification-preferences'
+ PerContractInvestmentsData,
+ OverallPerformanceData,
+} from 'functions/src/weekly-portfolio-emails'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
- const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as notification_preference
- }`
+ const { unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ 'onboarding_flow'
+ )
return await sendTemplateEmail(
privateUser.email,
@@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
- const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as notification_preference
- }`
+ const { unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ 'onboarding_flow'
+ )
+
return await sendTemplateEmail(
privateUser.email,
'Manifold Markets one week anniversary gift',
@@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
-
- const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as notification_preference
- }`
+ const { unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ 'onboarding_flow'
+ )
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
@@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
-
- const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'thank_you_for_purchases' as notification_preference
- }`
+ const { unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ 'thank_you_for_purchases'
+ )
return await sendTemplateEmail(
privateUser.email,
@@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
)
return
- const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'trending_markets' as notification_preference
- }`
+ const { unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ 'trending_markets'
+ )
const { name } = user
const firstName = name.split(' ')[0]
@@ -507,10 +512,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 +613,47 @@ 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 } = getNotificationDestinationsForUser(
+ privateUser,
+ 'profit_loss_updates'
+ )
+
+ 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
+ templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle
+ })
+
+ await sendTemplateEmail(
+ privateUser.email,
+ // 'iansphilips@gmail.com',
+ `Here's your weekly portfolio update!`,
+ investments.length === 0
+ ? 'portfolio-update-no-movers'
+ : 'portfolio-update',
+ templateData
+ )
+}
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 2cb6f515..4844cea8 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-portfolio-emails'
// v2
export * from './health'
diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flag.ts
index 947e3e88..9a769498 100644
--- a/functions/src/reset-weekly-emails-flag.ts
+++ b/functions/src/reset-weekly-emails-flag.ts
@@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
privateUsers.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false,
+ weeklyPortfolioEmailSent: false,
})
})
)
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..41aa9fe9
--- /dev/null
+++ b/functions/src/test-scheduled-function.ts
@@ -0,0 +1,17 @@
+import { APIError, newEndpoint } from './api'
+import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
+import { isProd } from './utils'
+
+// Function for testing scheduled functions locally
+export const testscheduledfunction = newEndpoint(
+ { method: 'GET', memory: '4GiB' },
+ async (_req) => {
+ if (isProd())
+ throw new APIError(400, 'This function is only available in dev mode')
+
+ // Replace your function here
+ 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-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts
new file mode 100644
index 00000000..dcbb68dd
--- /dev/null
+++ b/functions/src/weekly-portfolio-emails.ts
@@ -0,0 +1,280 @@
+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 { DAY_MS } from '../../common/util/time'
+import { 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 Friday for an hour at 12pm PT (UTC -07:00)
+ .pubsub.schedule('* 19 * * 5')
+ .timeZone('Etc/UTC')
+ .onRun(async () => {
+ await sendPortfolioUpdateEmailsToAllUsers()
+ })
+
+const firestore = admin.firestore()
+
+export async function sendPortfolioUpdateEmailsToAllUsers() {
+ const privateUsers = isProd()
+ ? // ian & stephen's ids
+ // ? filterDefined([
+ // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
+ // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
+ // ])
+ 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 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')
+ let count = 0
+ await Promise.all(
+ privateUsersToSendEmailsTo.map(async (privateUser) => {
+ const user = await getUser(privateUser.id)
+ if (!user) return
+ const userBets = usersBets[privateUser.id] as Bet[]
+ const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
+ userBets.some((bet) => bet.contractId === contract.id)
+ )
+ const contractsBetOnInLastWeek = uniq(
+ userBets
+ .filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS)
+ .map((bet) => bet.contractId)
+ )
+ const totalTips = sum(
+ usersToTxnsReceived[privateUser.id]
+ .filter((txn) => txn.category === 'TIP')
+ .map((txn) => txn.amount)
+ )
+ const greenBg = 'rgba(0,160,0,0.2)'
+ const redBg = 'rgba(160,0,0,0.2)'
+ const clearBg = 'rgba(255,255,255,0)'
+ const roundedProfit =
+ Math.round(user.profitCached.weekly) === 0
+ ? 0
+ : Math.floor(user.profitCached.weekly)
+ const performanceData = {
+ profit: formatMoney(user.profitCached.weekly),
+ profit_style: `background-color: ${
+ roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg
+ }`,
+ markets_created:
+ usersToContractsCreated[privateUser.id].length.toString(),
+ tips_received: formatMoney(totalTips),
+ unique_bettors: usersToTxnsReceived[privateUser.id]
+ .filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
+ .length.toString(),
+ markets_traded: contractsBetOnInLastWeek.length.toString(),
+ prediction_streak:
+ (user.currentBettingStreak?.toString() ?? '0') + ' days',
+ // More options: bonuses, tips given,
+ } as OverallPerformanceData
+
+ 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.resolutionProbability
+ ? cpmmContract.resolutionProbability
+ : cpmmContract.prob
+ const betsValueAWeekAgo = computeInvestmentValueCustomProb(
+ bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS),
+ contract,
+ marketProbabilityAWeekAgo
+ )
+ const currentBetsValue = computeInvestmentValueCustomProb(
+ bets,
+ contract,
+ currentMarketProbability
+ )
+ const marketChange =
+ currentMarketProbability - marketProbabilityAWeekAgo
+ return {
+ currentValue: currentBetsValue,
+ pastValue: betsValueAWeekAgo,
+ difference: currentBetsValue - betsValueAWeekAgo,
+ contractSlug: contract.slug,
+ marketProbAWeekAgo: marketProbabilityAWeekAgo,
+ questionTitle: contract.question,
+ questionUrl: contractUrl(contract),
+ questionProb: cpmmContract.resolution
+ ? cpmmContract.resolution
+ : Math.round(cpmmContract.prob * 100) + '%',
+ questionChange:
+ (marketChange > 0 ? '+' : '') +
+ Math.round(marketChange * 100) +
+ '%',
+ questionChangeStyle: `color: ${
+ currentMarketProbability > marketProbabilityAWeekAgo
+ ? 'rgba(0,160,0,1)'
+ : '#a80000'
+ };`,
+ } as PerContractInvestmentsData
+ })
+ ),
+ (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%
+ ),
+ (investmentsData: PerContractInvestmentsData) => {
+ return investmentsData.difference > 0
+ }
+ )
+ // pick 3 winning investments and 3 losing investments
+ const topInvestments = winningInvestments.slice(0, 2)
+ const worstInvestments = losingInvestments.slice(0, 2)
+ // if no bets in the last week ANd no market movers AND no markets created, don't send email
+ if (
+ contractsBetOnInLastWeek.length === 0 &&
+ topInvestments.length === 0 &&
+ worstInvestments.length === 0 &&
+ usersToContractsCreated[privateUser.id].length === 0
+ ) {
+ log('No bets in last week, no market movers, no markets created')
+ await firestore.collection('private-users').doc(privateUser.id).update({
+ weeklyPortfolioUpdateEmailSent: true,
+ })
+ return
+ }
+ await sendWeeklyPortfolioUpdateEmail(
+ user,
+ privateUser,
+ topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
+ performanceData
+ )
+ await firestore.collection('private-users').doc(privateUser.id).update({
+ weeklyPortfolioUpdateEmailSent: true,
+ })
+ log('Sent weekly portfolio update email to', privateUser.email)
+ count++
+ log('sent out emails to user count:', count)
+ })
+ )
+}
+
+export type PerContractInvestmentsData = {
+ questionTitle: string
+ questionUrl: string
+ questionProb: string
+ questionChange: string
+ questionChangeStyle: string
+ currentValue: number
+ pastValue: number
+ difference: number
+}
+
+export type OverallPerformanceData = {
+ profit: string
+ prediction_streak: string
+ markets_traded: string
+ profit_style: string
+ tips_received: string
+ markets_created: string
+ unique_bettors: string
+}