Stats computing correctly

This commit is contained in:
Ian Philips 2022-09-23 09:31:52 -04:00
parent 6d7fbd69c7
commit 1408f6c44e
9 changed files with 858 additions and 6 deletions

View File

@ -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 computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime

View File

@ -57,6 +57,7 @@ export type PrivateUser = {
email?: string email?: string
weeklyTrendingEmailSent?: boolean weeklyTrendingEmailSent?: boolean
weeklyPortfolioUpdateEmailSent?: boolean
manaBonusEmailSent?: boolean manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string

View File

@ -0,0 +1,490 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>State of your predictions on Manifold</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family:"Readex Pro", Helvetica, sans-serif;
}
table { margin: 0 auto; }
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
th {color:#000000; font-size:14px;}
th, td {padding: 5px; }
td{ font-size: 20px}
th, td { vertical-align: center; text-align: center }
a { vertical-align: center; text-align: left}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto"
src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align: top" width="100%">
<tbody>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
We ran the numbers and here's how you did this past week!
</span>
</p>
</div>
</td>
</tr>
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<th style='width: 160px'>
Investment value
</th>
<th>
Balance
</th>
<th>
Tips
</th>
<th>
Markets
</th>
<th style='width: 55px'>
Traders
</th>
</tr>
<tr>
<!-- <td style="{{investment_value_style}}">-->
<td>
<p style='display: inline'>
{{investment_value}}
<!-- M$10,000-->
<p style='font-size:14px;display: inline; padding: 2px; border-radius: 5px; background-color: rgba(160,0,0,0.2);'>
{{investment_change}}
<!-- +17%-->
</p>
</p>
</td>
<td>
{{current_balance}}
</td>
<td>
{{tips_received}}
</td>
<td>
{{markets_created}}
</td>
<td>
{{unique_bettors}}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
And here's some of the biggest changes in your portfolio:
</span>
</p>
</div>
</td>
<tr>
<td
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
<table role="presentation">
<tbody>
<tr>
<!-- <td style="{{investment_value_style}}">-->
<td style='text-align: left'>
<a href='{{question1Url}}'>
{{question1Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td style='padding-left: 10px; min-width: 115px'>
<p style='font-size: 24px;display: inline'>
{{question1Prob}}
<!-- 99.9%-->
<p style='font-size:14px;display: inline; padding: 2px; border-radius: 5px; background-color: rgba(160,0,0,0.2);'>
{{question1Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td style='text-align: left'>
<a href='{{question2Url}}'>
{{question2Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td style='padding-left: 10px; min-width: 115px'>
<p style='font-size: 24px;display: inline'>
{{question2Prob}}
<!-- 99.9%-->
<p style='font-size:14px;display: inline; padding: 2px; border-radius: 5px; background-color: rgba(160,0,0,0.2);'>
{{question2Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td style='text-align: left'>
<a href='{{question3Url}}'>
{{question3Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td style='padding-left: 10px; min-width: 115px'>
<p style='font-size: 24px;display: inline'>
{{question3Prob}}
<!-- 99.9%-->
<p style='font-size:14px;display: inline; padding: 2px; border-radius: 5px; background-color: rgba(160,0,0,0.2);'>
{{question3Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td style='text-align: left'>
<a href='{{question4Url}}'>
{{question4Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td style='padding-left: 10px; min-width: 115px'>
<p style='font-size: 24px;display: inline'>
{{question4Prob}}
<!-- 99.9%-->
<p style='font-size:14px;display: inline; padding: 2px; border-radius: 5px; background-color: rgba(160,0,0,0.2);'>
{{question4Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
">
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table border="0" cellpadding="0" cellspacing="0"
role="presentation" width="100%">
<tbody>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
">
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils' import { contractUrl, getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
@ -20,6 +20,10 @@ import {
getNotificationDestinationsForUser, getNotificationDestinationsForUser,
notification_preference, notification_preference,
} from '../../common/user-notification-preferences' } from '../../common/user-notification-preferences'
import {
PerContractInvestmentsData,
OverallPerformanceData,
} from './weekly-profit-loss-emails'
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
reason: notification_reason_types, 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) { function imageSourceUrl(contract: Contract) {
return buildCardUrl(getOpenGraphProps(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&section=${
'profit_loss_updates' as notification_preference
}`
const { name } = user
const firstName = name.split(' ')[0]
const templateData: Record<string, string> = {
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
)
}

View File

@ -30,6 +30,7 @@ export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag' export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like' export * from './on-update-like'
export * from './weekly-profit-loss-emails'
// v2 // v2
export * from './health' export * from './health'
@ -50,6 +51,7 @@ export * from './resolve-market'
export * from './unsubscribe' export * from './unsubscribe'
export * from './stripe' export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './test-scheduled-function'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -72,6 +74,7 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -98,6 +101,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost) const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const testScheduledFunction = toCloudFunction(testscheduledfunction)
export { export {
healthFunction as health, healthFunction as health,
@ -121,5 +125,6 @@ export {
getCurrentUserFunction as getcurrentuser, getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials saveTwitchCredentials as savetwitchcredentials,
testScheduledFunction as testscheduledfunction,
} }

View File

@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost) addEndpointRoute('/createpost', createpost)
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View File

@ -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 }
}
)

View File

@ -170,3 +170,7 @@ export const chargeUser = (
export const getContractPath = (contract: Contract) => { export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}` return `/${contract.creatorUsername}/${contract.slug}`
} }
export function contractUrl(contract: Contract) {
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
}

View File

@ -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<PortfolioMetrics>(
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<Bet>(
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<Contract>(
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<Txn>(
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<Contract>(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
}