Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-10-11 12:43:58 -07:00 committed by GitHub
commit 2e01e2d76b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
222 changed files with 6062 additions and 3334 deletions

View File

@ -0,0 +1,17 @@
name: Merge main into main2 on every commit
on:
push:
branches:
- 'main'
jobs:
merge-branch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Merge main -> main2
uses: devmasx/merge-branch@master
with:
type: now
target_branch: main2
github_token: ${{ github.token }}

View File

@ -1,4 +1,4 @@
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
import { getCpmmLiquidity } from './calculate-cpmm'
import { CPMMContract } from './contract'
import { LiquidityProvision } from './liquidity-provision'
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
contract: CPMMContract,
newLiquidityProvisionId: string
) => {
const { pool, p, totalLiquidity } = contract
const { pool, p, totalLiquidity, subsidyPool } = contract
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
const liquidity =
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
const liquidity = getCpmmLiquidity(pool, p)
const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId,
userId: userId,
contractId: contract.id,
amount,
pool: newPool,
p: newP,
pool,
p,
liquidity,
createdTime: Date.now(),
}
const newTotalLiquidity = (totalLiquidity ?? 0) + amount
const newSubsidyPool = (subsidyPool ?? 0) + amount
return { newLiquidityProvision, newPool, newP, newTotalLiquidity }
return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool }
}

123
common/badge.ts Normal file
View File

@ -0,0 +1,123 @@
import { User } from './user'
export type Badge = {
type: BadgeTypes
createdTime: number
data: { [key: string]: any }
name: 'Proven Correct' | 'Streaker' | 'Market Creator'
}
export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
export type ProvenCorrectBadgeData = {
type: 'PROVEN_CORRECT'
data: {
contractSlug: string
contractCreatorUsername: string
contractTitle: string
commentId: string
betAmount: number
}
}
export type MarketCreatorBadgeData = {
type: 'MARKET_CREATOR'
data: {
totalContractsCreated: number
}
}
export type StreakerBadgeData = {
type: 'STREAKER'
data: {
totalBettingStreak: number
}
}
export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
export type StreakerBadge = Badge & StreakerBadgeData
export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
export const provenCorrectRarityThresholds = [1, 1000, 10000]
const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
const { betAmount } = badge.data
const thresholdArray = provenCorrectRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (betAmount >= thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export const streakerBadgeRarityThresholds = [1, 50, 250]
const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
const { totalBettingStreak } = badge.data
const thresholdArray = streakerBadgeRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (totalBettingStreak == thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
const { totalContractsCreated } = badge.data
const thresholdArray = marketCreatorBadgeRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (totalContractsCreated == thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export type rarities = 'bronze' | 'silver' | 'gold'
const rarityRanks: { [key: number]: rarities } = {
1: 'bronze',
2: 'silver',
3: 'gold',
}
export const calculateBadgeRarity = (badge: Badge) => {
switch (badge.type) {
case 'PROVEN_CORRECT':
return rarityRanks[
calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
]
case 'MARKET_CREATOR':
return rarityRanks[
calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
]
case 'STREAKER':
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
default:
return rarityRanks[0]
}
}
export const getBadgesByRarity = (user: User | null | undefined) => {
const rarities: { [key in rarities]: number } = {
bronze: 0,
silver: 0,
gold: 0,
}
if (!user) return rarities
Object.values(user.achievements).map((value) => {
value.badges.map((badge) => {
rarities[calculateBadgeRarity(badge)] =
(rarities[calculateBadgeRarity(badge)] ?? 0) + 1
})
})
return rarities
}

View File

@ -1,11 +1,10 @@
import { sum, groupBy, mapValues, sumBy } from 'lodash'
import { groupBy, mapValues, sumBy } from 'lodash'
import { LimitBet } from './bet'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet'
import { binarySearch } from './util/algos'
import { addObjects } from './util/object'
export type CpmmState = {
pool: { [outcome: string]: number }
@ -147,7 +146,8 @@ function calculateAmountToBuyShares(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
// Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each.
@ -157,7 +157,8 @@ function calculateAmountToBuyShares(
amount,
state,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
const totalShares = sumBy(takers, (taker) => taker.shares)
@ -169,7 +170,8 @@ export function calculateCpmmSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares')
@ -180,15 +182,17 @@ export function calculateCpmmSale(
state,
shares,
oppositeOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
const { cpmmState, makers, takers, totalFees } = computeFills(
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
oppositeOutcome,
buyAmount,
state,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
// Transform buys of opposite outcome into sells.
@ -211,6 +215,7 @@ export function calculateCpmmSale(
fees: totalFees,
makers,
takers: saleTakers,
ordersToCancel,
}
}
@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
const { cpmmState } = calculateCpmmSale(
state,
shares,
outcome,
unfilledBets,
balanceByUserId
)
return getCpmmProbability(cpmmState.pool, cpmmState.p)
}
@ -254,48 +266,22 @@ export function addCpmmLiquidity(
return { newPool, liquidity, newP }
}
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
const oldLiquidity = getCpmmLiquidity(l.pool, p)
export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
const userAmounts = groupBy(liquidities, (w) => w.userId)
const totalAmount = sumBy(liquidities, (w) => w.amount)
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
const newLiquidity = getCpmmLiquidity(newPool, p)
const liquidity = newLiquidity - oldLiquidity
return liquidity
}
export function getCpmmLiquidityPoolWeights(
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
) {
const calcLiqudity = calculateLiquidityDelta(state.p)
const liquidityShares = liquidities.map(calcLiqudity)
const shareSum = sum(liquidityShares)
const weights = liquidityShares.map((shares, i) => ({
weight: shares / shareSum,
providerId: liquidities[i].userId,
}))
const includedWeights = excludeAntes
? weights.filter((_, i) => !liquidities[i].isAnte)
: weights
const userWeights = groupBy(includedWeights, (w) => w.providerId)
const totalUserWeights = mapValues(userWeights, (userWeight) =>
sumBy(userWeight, (w) => w.weight)
return mapValues(
userAmounts,
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
)
return totalUserWeights
}
export function getUserLiquidityShares(
userId: string,
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
liquidities: LiquidityProvision[]
) {
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
const weights = getCpmmLiquidityPoolWeights(liquidities)
const userWeight = weights[userId] ?? 0
return mapValues(state.pool, (shares) => userWeight * shares)

View File

@ -1,9 +1,17 @@
import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet } from './bet'
import { Contract } from './contract'
import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash'
import { calculatePayout, getContractBetMetrics } from './calculate'
import { Bet, LimitBet } from './bet'
import {
Contract,
CPMMBinaryContract,
CPMMContract,
DPMContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = (
bets: Bet[],
@ -33,13 +41,81 @@ export const computeInvestmentValueCustomProb = (
const betP = outcome === 'YES' ? p : 1 - p
const payout = betP * shares
const value = payout - (bet.loanAmount ?? 0)
const value = betP * shares
if (isNaN(value)) return 0
return value
})
}
export const computeElasticity = (
bets: Bet[],
contract: Contract,
betAmount = 50
) => {
const { mechanism, outcomeType } = contract
return mechanism === 'cpmm-1' &&
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC')
? computeBinaryCpmmElasticity(bets, contract, betAmount)
: computeDpmElasticity(contract, betAmount)
}
export const computeBinaryCpmmElasticity = (
bets: Bet[],
contract: CPMMContract,
betAmount: number
) => {
const limitBets = bets
.filter(
(b) =>
!b.isFilled &&
!b.isSold &&
!b.isRedemption &&
!b.sale &&
!b.isCancelled &&
b.limitProb !== undefined
)
.sort((a, b) => a.createdTime - b.createdTime) as LimitBet[]
const userIds = uniq(limitBets.map((b) => b.userId))
// Assume all limit orders are good.
const userBalances = Object.fromEntries(
userIds.map((id) => [id, Number.MAX_SAFE_INTEGER])
)
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
'YES',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultYes = getCpmmProbability(poolY, pY)
const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo(
'NO',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultNo = getCpmmProbability(poolN, pN)
// handle AMM overflow
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
return safeYes - safeNo
}
export const computeDpmElasticity = (
contract: DPMContract,
betAmount: number
) => {
return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
@ -123,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
}
const calculateProfitForPeriod = (
startTime: number,
descendingPortfolio: PortfolioMetrics[],
startingPortfolio: PortfolioMetrics | undefined,
currentProfit: number
) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) {
return currentProfit
}
@ -145,33 +216,90 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
portfolioHistory: Record<
'current' | 'day' | 'week' | 'month',
PortfolioMetrics | undefined
>,
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
allTime: allTimeProfit,
}
return newProfit
}
export const calculateMetricsByContract = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const unresolvedContracts = Object.keys(betsByContract)
.map((cid) => contractsById[cid])
.filter((c) => c && !c.isResolved)
return unresolvedContracts.map((c) => {
const bets = betsByContract[c.id] ?? []
const current = getContractBetMetrics(c, bets)
let periodMetrics
if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
const periods = ['day', 'week', 'month'] as const
periodMetrics = Object.fromEntries(
periods.map((period) => [
period,
calculatePeriodProfit(c, bets, period),
])
)
}
return removeUndefinedProps({
contractId: c.id,
...current,
from: periodMetrics,
})
})
}
export type ContractMetrics = ReturnType<typeof calculateMetricsByContract>[number]
const calculatePeriodProfit = (
contract: CPMMBinaryContract,
bets: Bet[],
period: 'day' | 'week' | 'month'
) => {
const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
const fromTime = Date.now() - days * DAY_MS
const previousBets = bets.filter((b) => b.createdTime < fromTime)
const prevProb = contract.prob - contract.probChanges[period]
const prob = contract.resolutionProbability
? contract.resolutionProbability
: contract.prob
const previousBetsValue = computeInvestmentValueCustomProb(
previousBets,
contract,
prevProb
)
const currentBetsValue = computeInvestmentValueCustomProb(
previousBets,
contract,
prob
)
const profit = currentBetsValue - previousBetsValue
const profitPercent =
previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue)
return {
profit,
profitPercent,
prevValue: previousBetsValue,
value: currentBetsValue,
}
}

View File

@ -78,7 +78,8 @@ export function calculateShares(
export function calculateSaleAmount(
contract: Contract,
bet: Bet,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
@ -87,7 +88,8 @@ export function calculateSaleAmount(
contract,
Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO',
unfilledBets
unfilledBets,
balanceByUserId
).saleValue
: calculateDpmSaleAmount(contract, bet)
}
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
contract: Contract,
outcome: string,
shares: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale(
contract,
shares,
outcome as 'YES' | 'NO',
unfilledBets
unfilledBets,
balanceByUserId
)
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
}
@ -174,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) {
})
}
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1'
@ -210,9 +216,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
}
}
const netPayout = payout - loan
const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100
const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
const hasShares = Object.values(totalShares).some(
@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return {
invested,
loan,
payout,
netPayout,
profit,
profitPercent,
totalShares,
@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() {
return {
invested: 0,
loan: 0,
payout: 0,
netPayout: 0,
profit: 0,
profitPercent: 0,
totalShares: {} as { [outcome: string]: number },

View File

@ -576,7 +576,7 @@ Work towards sustainable, systemic change.`,
If you would like to support our work, you can do so by getting involved or by donating.`,
},
{
{
name: 'CaRLA',
website: 'https://carlaef.org/',
photo: 'https://i.imgur.com/IsNVTOY.png',
@ -589,6 +589,14 @@ CaRLA uses legal advocacy and education to ensure all cities comply with their o
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
},
{
name: 'Mriya',
website: 'https://mriya-ua.org/',
photo:
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637',
preview: 'Donate supplies to soldiers in Ukraine',
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`)
return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +

View File

@ -49,6 +49,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume: number
volume24Hours: number
volume7Days: number
elasticity: number
collectedFees: Fees
@ -62,7 +63,9 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
flaggedByUsernames?: string[]
openCommentBounties?: number
unlistedById?: string
} & T
export type BinaryContract = Contract & Binary
@ -88,7 +91,8 @@ export type CPMM = {
mechanism: 'cpmm-1'
pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$
totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$
subsidyPool: number // current value of subsidy pool in M$
prob: number
probChanges: {
day: number

View File

@ -11,8 +11,10 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
export const UNIQUE_BETTOR_LIQUIDITY = 20

View File

@ -1,3 +1,5 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0

View File

@ -39,3 +39,4 @@ export type GroupLink = {
createdTime: number
userId?: string
}
export type GroupContractDoc = { contractId: string; createdTime: number }

View File

@ -17,8 +17,7 @@ import {
import {
CPMMBinaryContract,
DPMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
DPMContract,
NumericContract,
PseudoNumericContract,
} from './contract'
@ -144,7 +143,8 @@ export const computeFills = (
betAmount: number,
state: CpmmState,
limitProb: number | undefined,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}')
@ -166,10 +166,12 @@ export const computeFills = (
shares: number
timestamp: number
}[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0
while (true) {
@ -186,9 +188,20 @@ export const computeFills = (
takers.push(taker)
} else {
// Matched against bet.
i++
const { userId } = maker.bet
const makerBalance = currentBalanceByUserId[userId]
if (floatingGreaterEqual(makerBalance, maker.amount)) {
currentBalanceByUserId[userId] = makerBalance - maker.amount
} else {
// Insufficient balance. Cancel maker bet.
ordersToCancel.push(maker.bet)
continue
}
takers.push(taker)
makers.push(maker)
i++
}
amount -= taker.amount
@ -196,7 +209,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break
}
return { takers, makers, totalFees, cpmmState }
return { takers, makers, totalFees, cpmmState, ordersToCancel }
}
export const getBinaryCpmmBetInfo = (
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills(
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
outcome,
betAmount,
{ pool, p },
limitProb,
unfilledBets
unfilledBets,
balanceByUserId
)
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p,
newTotalLiquidity,
makers,
ordersToCancel,
}
}
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
const { newBet } = getBinaryCpmmBetInfo(
outcome,
betAmount ?? 0,
contract,
limitProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) /
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract | MultipleChoiceContract
contract: DPMContract
) => {
const { pool, totalShares, totalBets } = contract

View File

@ -12,7 +12,6 @@ import {
visibility,
} from './contract'
import { User } from './user'
import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core'
@ -38,15 +37,6 @@ export function getNewContract(
answers: string[],
visibility: visibility
) {
const tags = parseTags(
[
question,
richTextToString(description),
...extraTags.map((tag) => `#${tag}`),
].join(' ')
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
@ -70,9 +60,10 @@ export function getNewContract(
question: question.trim(),
description,
tags,
lowercaseTags,
tags: [],
lowercaseTags: [],
visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false,
createdTime: Date.now(),
closeTime,
@ -80,6 +71,7 @@ export function getNewContract(
volume: 0,
volume24Hours: 0,
volume7Days: 0,
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
collectedFees: {
creatorFee: 0,
@ -120,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
mechanism: 'cpmm-1',
outcomeType: 'BINARY',
totalLiquidity: ante,
subsidyPool: 0,
initialProbability: p,
p,
pool: pool,

View File

@ -4,7 +4,7 @@ export type Notification = {
id: string
userId: string
reasonText?: string
reason?: notification_reason_types
reason?: notification_reason_types | notification_preference
createdTime: number
viewTime?: number
isSeen: boolean
@ -46,6 +46,7 @@ export type notification_source_types =
| 'loan'
| 'like'
| 'tip_and_like'
| 'badge'
export type notification_source_update_types =
| 'created'
@ -96,6 +97,7 @@ type notification_descriptions = {
[key in notification_preference]: {
simple: string
detailed: string
necessary?: boolean
}
}
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@ -208,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets',
},
your_contract_closed: {
simple: 'Your market has closed and you need to resolve it',
detailed: 'Your market has closed and you need to resolve it',
simple: 'Your market has closed and you need to resolve it (necessary)',
detailed: 'Your market has closed and you need to resolve it (necessary)',
necessary: true,
},
all_comments_on_watched_markets: {
simple: 'All new comments',
@ -235,6 +238,15 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`,
},
badges_awarded: {
simple: 'New badges awarded',
detailed: 'New badges you have earned',
},
opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:
'Opt out of all notifications excluding your own market closure notifications',
},
}
export type BettingStreakData = {

View File

@ -1,4 +1,3 @@
import { Bet } from './bet'
import { getProbability } from './calculate'
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
@ -56,10 +55,10 @@ export const getLiquidityPoolPayouts = (
outcome: string,
liquidities: LiquidityProvision[]
) => {
const { pool } = contract
const finalPool = pool[outcome]
const { pool, subsidyPool } = contract
const finalPool = pool[outcome] + subsidyPool
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
const weights = getCpmmLiquidityPoolWeights(liquidities)
return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId,
@ -95,10 +94,10 @@ export const getLiquidityPoolProbPayouts = (
p: number,
liquidities: LiquidityProvision[]
) => {
const { pool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO
const { pool, subsidyPool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
const weights = getCpmmLiquidityPoolWeights(liquidities)
return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId,

View File

@ -3,6 +3,7 @@ import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
subtitle: string
content: JSONContent
creatorId: string // User id
createdTime: number
@ -17,3 +18,4 @@ export type DateDoc = Post & {
}
export const MAX_POST_TITLE_LENGTH = 480
export const MAX_POST_SUBTITLE_LENGTH = 480

View File

@ -1,8 +1,9 @@
import { groupBy, sumBy, mapValues } from 'lodash'
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import { getContractBetMetrics, resolvedPayout } from './calculate'
import { Contract } from './contract'
import { ContractComment } from './comment'
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
}
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const betsByUser = groupBy(bets, bet => bet.userId)
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
const betsByUser = groupBy(bets, (bet) => bet.userId)
return mapValues(
betsByUser,
(bets) => getContractBetMetrics(contract, bets).profit
)
}
export function addUserScores(
@ -43,3 +47,47 @@ export function addUserScores(
dest[userId] += score
}
}
export function scoreCommentorsAndBettors(
contract: Contract,
bets: Bet[],
comments: ContractComment[]
) {
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = betsById[topBetId]?.userName
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
const topCommentBetId = commentsById[topCommentId]?.betId
return {
topCommentId,
topBetId,
topBettor,
profitById,
commentsById,
betsById,
topCommentBetId,
}
}

View File

@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO',
contract: CPMMContract,
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number
) => {
const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
contract,
shares,
outcome,
unfilledBets
unfilledBets,
balanceByUserId,
)
const probBefore = getCpmmProbability(pool, p)
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
fees,
makers,
takers,
ordersToCancel
}
}

View File

@ -53,6 +53,9 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
badges_awarded: notification_destination_types[]
opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
}
export const getDefaultNotificationPreferences = (
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
const defaults: notification_preferences = {
// Watched Markets
all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false),
@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = (
loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false),
unique_bettors_on_your_contract: constructPref(true, true),
tipped_comments_on_watched_markets: constructPref(true, true),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
@ -121,7 +124,11 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false),
onboarding_flow: constructPref(false, false),
} as notification_preferences
opt_out_all: [],
badges_awarded: constructPref(true, false),
}
return defaults
}
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
@ -172,23 +179,44 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference
) => {
const notificationSettings = privateUser.notificationPreferences
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
try {
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
} catch (e) {
// Fail safely
console.log(
`couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
)
return {
sendToEmail: false,
sendToBrowser: false,
unsubscribeUrl: '',
urlToManageThisNotification: '',
}
}
}

View File

@ -1,5 +1,6 @@
import { notification_preferences } from './user-notification-preferences'
import { ENV_CONFIG } from 'common/envs/constants'
import { ENV_CONFIG } from './envs/constants'
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
export type User = {
id: string
@ -11,7 +12,6 @@ export type User = {
// For their user page
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
@ -33,6 +33,8 @@ export type User = {
allTime: number
}
fractionResolvedCorrectly: number
nextLoanCached: number
followerCountCached: number
@ -49,6 +51,18 @@ export type User = {
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
isBannedFromPosting?: boolean
achievements: {
provenCorrect?: {
badges: ProvenCorrectBadge[]
}
marketCreator?: {
badges: MarketCreatorBadge[]
}
streaker?: {
badges: StreakerBadge[]
}
}
}
export type PrivateUser = {
@ -79,7 +93,8 @@ export type PortfolioMetrics = {
userId: string
}
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
// TODO: remove. Hardcoding the strings would be better.

24
common/util/color.ts Normal file
View File

@ -0,0 +1,24 @@
export const interpolateColor = (color1: string, color2: string, p: number) => {
const rgb1 = parseInt(color1.replace('#', ''), 16)
const rgb2 = parseInt(color2.replace('#', ''), 16)
const [r1, g1, b1] = toArray(rgb1)
const [r2, g2, b2] = toArray(rgb2)
const q = 1 - p
const rr = Math.round(r1 * q + r2 * p)
const rg = Math.round(g1 * q + g2 * p)
const rb = Math.round(b1 * q + b2 * p)
const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16)
const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}`
return hex
}
function toArray(rgb: number) {
const r = rgb >> 16
const g = (rgb >> 8) % 256
const b = rgb % 256
return [r, g, b]
}

View File

@ -13,7 +13,9 @@ export function formatMoney(amount: number) {
Math.round(amount) === 0
? 0
: // Handle 499.9999999999999 case
Math.floor(amount + 0.00000000001 * Math.sign(amount))
(amount > 0 ? Math.floor : Math.ceil)(
amount + 0.00000000001 * Math.sign(amount)
)
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
}
@ -58,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
return `${numStr}${suffix[i] ?? ''}`
}
export function shortFormatNumber(num: number): string {
if (num < 1000) return showPrecision(num, 3)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const i = Math.floor(Math.log10(num) / 3)
const numStr = showPrecision(num / Math.pow(10, 3 * i), 2)
return `${numStr}${suffix[i] ?? ''}`
}
export function toCamelCase(words: string) {
const camelCase = words
.split(' ')

View File

@ -1,4 +1,3 @@
import { MAX_TAG_LENGTH } from '../contract'
import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote'
@ -24,7 +23,7 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs'
import { uniq } from 'lodash'
import { cloneDeep, uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
@ -33,34 +32,6 @@ export function getUrl(text: string) {
return results.length ? results[0].href : null
}
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) =>
match.trim().substring(1).substring(0, MAX_TAG_LENGTH)
)
const tagSet = new Set()
const uniqueTags: string[] = []
// Keep casing of last tag.
matches.reverse()
for (const tag of matches) {
const lowercase = tag.toLowerCase()
if (!tagSet.has(lowercase)) {
tagSet.add(lowercase)
uniqueTags.push(tag)
}
}
uniqueTags.reverse()
return uniqueTags
}
export function parseWordsAsTags(text: string) {
const taggedText = text
.split(/\s+/)
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
.join(' ')
return parseTags(taggedText)
}
// TODO: fuzzy matching
export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
@ -81,7 +52,7 @@ export function parseMentions(data: JSONContent): string[] {
}
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
export const exhibitExts = [
const stringParseExts = [
Blockquote,
Bold,
BulletList,
@ -101,12 +72,35 @@ export const exhibitExts = [
Image,
Link,
Mention,
Mention, // user @mention
Mention.extend({ name: 'contract-mention' }), // market %mention
Iframe,
TiptapTweet,
TiptapSpoiler,
]
export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts)
if (!text) return ''
// remove spoiler tags.
const newText = cloneDeep(text)
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
} else if (current.type === 'image') {
current.text = '[Image]'
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
} else if (current.type === 'iframe') {
const src = current.attrs?.['src'] ? current.attrs['src'] : ''
current.text = '[Iframe]' + (src ? ` url:${src}` : '')
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
}
})
return generateText(newText, stringParseExts)
}
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
data.content?.forEach((d) => dfs(d, f))
f(data)
}

View File

@ -39,7 +39,7 @@ export const TiptapSpoiler = Mark.create<SpoilerOptions>({
exitable: true,
content: 'inline*',
priority: 200, // higher priority than other formatting so they go inside
priority: 1001, // higher priority than other formatting so they go inside
addOptions() {
return {

View File

@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties
💥 *Awarded on 2022-10-07*
**[Pepe](https://manifold.markets/Pepe): M$10,000**
**[Jack](https://manifold.markets/jack): M$2,000**
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
**[Yev](https://manifold.markets/Yev): M$2,000**
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
**[Matt](https://manifold.markets/MattP): M$5,000**
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
**[Yev](https://manifold.markets/Yev): M$5,000**
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
.hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -44,6 +44,10 @@ service cloud.firestore {
allow read;
}
match /{somePath=**}/contract-metrics/{contractId} {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{
allow read;
}
@ -102,7 +106,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question'])
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid;
allow update: if isAdmin();
match /comments/{commentId} {

3
functions/.env.dev Normal file
View File

@ -0,0 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=DEV

View File

@ -5,7 +5,7 @@
"firestore": "dev-mantic-markets.appspot.com"
},
"scripts": {
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist",
"compile": "tsc -b",
"watch": "tsc -w",
"shell": "yarn build && firebase functions:shell",

View File

@ -3,24 +3,18 @@ import { z } from 'zod'
import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
export const addliquidity = newEndpoint({}, async (req, auth) => {
export const addsubsidy = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
.collection(`contracts/${contractId}/liquidity`)
.doc()
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision(
user.id,
amount,
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
return {
status: 'error',
message: 'Liquidity injection rejected due to overflow error.',
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
transaction.update(contractDoc, {
subsidyPool: newSubsidyPool,
totalLiquidity: newTotalLiquidity,
} as Partial<CPMMContract>)
const newBalance = user.balance - amount
const newTotalDeposits = user.totalDeposits - amount
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
})
const firestore = admin.firestore()
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
transaction.update(
firestore.doc(`contracts/${contract.id}`),
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
},
} as EndpointDefinition
}
export const newEndpointNoAuth = (
endpointOpts: EndpointOptions,
fn: (req: Request) => Promise<Output>
) => {
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return {
opts,
handler: async (req: Request, res: Response) => {
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
try {
if (opts.method !== req.method) {
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
}
res.status(200).json(await fn(req))
} catch (e) {
writeResponseError(e, res)
}
},
} as EndpointDefinition
}

View File

@ -6,7 +6,13 @@ import {
Notification,
notification_reason_types,
} from '../../common/notification'
import { User } from '../../common/user'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USER_NAME,
MANIFOLD_USER_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
@ -30,27 +36,26 @@ import {
import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow'
import { Badge } from 'common/badge'
const firestore = admin.firestore()
type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
}
export const createNotification = async (
export const createFollowOrMarketSubsidizedNotification = async (
sourceId: string,
sourceType: 'contract' | 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created',
sourceType: 'liquidity' | 'follow',
sourceUpdateType: 'created',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
miscData?: {
contract?: Contract
recipients?: string[]
slug?: string
title?: string
}
) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const { contract: sourceContract, recipients } = miscData ?? {}
const shouldReceiveNotification = (
userId: string,
@ -94,23 +99,15 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug,
sourceSlug: slug ? slug : sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question,
sourceSlug: sourceContract?.slug,
sourceTitle: sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
}
if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
// TODO: include number and names of bettors waiting for creator to resolve their market
await sendMarketCloseEmail(
reason,
sourceUser,
privateUser,
sourceContract
)
} else if (reason === 'subsidized_your_market') {
if (reason === 'subsidized_your_market') {
// TODO: send email to creator of market that was subsidized
} else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed
@ -127,20 +124,7 @@ export const createNotification = async (
reason: 'on_new_follow',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
} else if (sourceType === 'liquidity' && sourceContract) {
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market',
@ -1087,6 +1071,81 @@ export const createBountyNotification = async (
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
// maybe TODO: send email notification to comment creator
}
export const createBadgeAwardedNotification = async (
user: User,
badge: Badge
) => {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'badges_awarded'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: user.id,
reason: 'badges_awarded',
createdTime: Date.now(),
isSeen: false,
sourceId: badge.type,
sourceType: 'badge',
sourceUpdateType: 'created',
sourceUserName: MANIFOLD_USER_NAME,
sourceUserUsername: MANIFOLD_USER_USERNAME,
sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
sourceText: `You earned a new ${badge.name} badge!`,
sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
sourceTitle: badge.name,
data: {
badge,
},
}
return await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
}
export const createMarketClosedNotification = async (
contract: Contract,
creator: User,
privateUser: PrivateUser,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${creator.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: creator.id,
reason: 'your_contract_closed',
createdTime: Date.now(),
isSeen: false,
sourceId: contract.id,
sourceType: 'contract',
sourceUpdateType: 'closed',
sourceContractId: contract?.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: contract.closeTime?.toString() ?? new Date().toString(),
sourceContractCreatorUsername: creator.username,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
await sendMarketCloseEmail(
'your_contract_closed',
creator,
privateUser,
contract
)
}

View File

@ -3,7 +3,11 @@ import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import {
Post,
MAX_POST_TITLE_LENGTH,
MAX_POST_SUBTITLE_LENGTH,
} from '../../common/post'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
@ -36,6 +40,7 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
content: contentSchema,
groupId: z.string().optional(),
@ -48,10 +53,8 @@ const postSchema = z.object({
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content, groupId, question, ...otherProps } = validate(
postSchema,
req.body
)
const { title, subtitle, content, groupId, question, ...otherProps } =
validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
@ -68,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => {
if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3
const result = await createMarketHelper(
{
question,
closeTime,
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
contractSlug = result.slug
try {
const result = await createMarketHelper(
{
question,
closeTime,
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
contractSlug = result.slug
} catch (e) {
console.error(e)
}
}
const post: Post = removeUndefinedProps({
@ -89,6 +96,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
creatorId: creator.id,
slug,
title,
subtitle,
createdTime: Date.now(),
content: content,
contractSlug,

View File

@ -69,6 +69,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
achievements: {},
}
await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -0,0 +1,69 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../common/contract'
import { batchedWaitAll } from '../../common/util/promise'
import { APIError } from '../../common/api'
import { addCpmmLiquidity } from '../../common/calculate-cpmm'
import { formatMoneyWithDecimals } from '../../common/util/format'
const firestore = admin.firestore()
export const drizzleLiquidity = async () => {
const snap = await firestore
.collection('contracts')
.where('subsidyPool', '>', 1e-7)
.get()
const contractIds = snap.docs.map((doc) => doc.id)
console.log('found', contractIds.length, 'markets to drizzle')
console.log()
await batchedWaitAll(
contractIds.map((cid) => () => drizzleMarket(cid)),
10
)
}
export const drizzleLiquidityScheduler = functions.pubsub
.schedule('* * * * *') // every minute
.onRun(drizzleLiquidity)
const drizzleMarket = async (contractId: string) => {
await firestore.runTransaction(async (trans) => {
const snap = await trans.get(firestore.doc(`contracts/${contractId}`))
const contract = snap.data() as CPMMContract
const { subsidyPool, pool, p, slug, popularityScore } = contract
if ((subsidyPool ?? 0) < 1e-7) return
const r = Math.random()
const logPopularity = Math.log10((popularityScore ?? 0) + 1)
const v = Math.max(1, Math.min(5, logPopularity))
const amount = subsidyPool <= 0.5 ? subsidyPool : r * v * 0.01 * subsidyPool
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
if (!isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
await trans.update(firestore.doc(`contracts/${contract.id}`), {
pool: newPool,
p: newP,
subsidyPool: subsidyPool - amount,
})
console.log(
'added subsidy',
formatMoneyWithDecimals(amount),
'of',
formatMoneyWithDecimals(subsidyPool),
'pool to',
slug
)
console.log()
})
}

View File

@ -483,11 +483,7 @@
color: #999;
text-decoration: underline;
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
">our Discord</a>!
</td>
</tr>
</table>

View File

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { contractUrl, getUser } from './utils'
import { contractUrl, getUser, log } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
user: User,
privateUser: PrivateUser
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser,
sendTime: string
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
user: User,
privateUser: PrivateUser
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'thank_you_for_purchases'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
'Thanks for your Manifold purchase',
@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser,
contract: Contract
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return
if (!privateUser.email) return
const { username, name, id: userId } = user
const firstName = name.split(' ')[0]
@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async (
const url = `https://${DOMAIN}/${username}/${slug}`
// We ignore if they were able to unsubscribe from market close emails, this is a necessary email
return await sendTemplateEmail(
privateUser.email,
'Your market has closed',
@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async (
{
question,
url,
unsubscribeUrl,
unsubscribeUrl: '',
userId,
name: firstName,
volume: formatMoney(volume),
@ -466,17 +448,13 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[],
deliveryTime?: string
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.trending_markets.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'trending_markets'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
@ -620,18 +598,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'profit_loss_updates'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
const templateData: Record<string, string> = {
@ -656,4 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
: 'portfolio-update',
templateData
)
log('Sent portfolio update email to', privateUser.email)
}

View File

@ -0,0 +1,42 @@
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../../common/contract'
import { isProd } from '../utils'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../../common/antes'
import { getNewLiquidityProvision } from '../../../common/add-liquidity'
const firestore = admin.firestore()
export const addHouseSubsidy = (contractId: string, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contractId}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const contractDoc = firestore.doc(`contracts/${contractId}`)
const snap = await contractDoc.get()
const contract = snap.data() as CPMMContract
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
transaction.update(contractDoc, {
subsidyPool: newSubsidyPool,
totalLiquidity: newTotalLiquidity,
} as Partial<CPMMContract>)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet'
export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './update-metrics'
export { scheduleUpdateMetrics } from './update-metrics'
export * from './update-stats'
export * from './update-loans'
export * from './backup-db'
@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags'
export * from './on-update-contract-follow'
export * from './on-update-like'
export * from './weekly-portfolio-emails'
export * from './drizzle-liquidity'
// v2
export * from './health'
@ -44,8 +45,6 @@ export * from './sell-bet'
export * from './sell-shares'
export * from './claim-manalink'
export * from './create-market'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
@ -53,6 +52,7 @@ export * from './stripe'
export * from './mana-bonus-email'
export * from './close-market'
export * from './update-comment-bounty'
export * from './add-subsidy'
import { health } from './health'
import { transact } from './transact'
@ -65,9 +65,7 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market'
@ -77,6 +75,8 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
import { addsubsidy } from './add-subsidy'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -92,10 +92,9 @@ const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
const addSubsidyFunction = toCloudFunction(addsubsidy)
const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket)
@ -106,6 +105,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export {
healthFunction as health,
@ -119,8 +119,7 @@ export {
sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket,
addLiquidityFunction as addliquidity,
withdrawLiquidityFunction as withdrawliquidity,
addSubsidyFunction as addsubsidy,
createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket,
@ -133,4 +132,5 @@ export {
saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
}

View File

@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils'
import { createNotification } from './create-notification'
import { createMarketClosedNotification } from './create-notification'
import { DAY_MS } from '../../common/util/time'
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
export const marketCloseNotifications = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('every 1 hours')
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
const firestore = admin.firestore()
async function sendMarketCloseEmails() {
export async function sendMarketCloseEmails() {
const contracts = await firestore.runTransaction(async (transaction) => {
const snap = await transaction.get(
firestore.collection('contracts').where('isResolved', '!=', true)
)
const contracts = snap.docs.map((doc) => doc.data() as Contract)
const now = Date.now()
const closeContracts = contracts.filter(
(contract) =>
contract.closeTime &&
contract.closeTime < now &&
shouldSendFirstOrFollowUpCloseNotification(contract)
)
return snap.docs
.map((doc) => {
const contract = doc.data() as Contract
if (
contract.resolution ||
(contract.closeEmailsSent ?? 0) >= 1 ||
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
await Promise.all(
closeContracts.map(async (contract) => {
await transaction.update(
firestore.collection('contracts').doc(contract.id),
{
closeEmailsSent: admin.firestore.FieldValue.increment(1),
}
)
return undefined
transaction.update(doc.ref, {
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
})
return contract
})
.filter((x) => !!x) as Contract[]
)
return closeContracts
})
for (const contract of contracts) {
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue
await createNotification(
contract.id,
'contract',
'closed',
await createMarketClosedNotification(
contract,
user,
contract.id + '-closed-at-' + contract.closeTime,
contract.closeTime?.toString() ?? new Date().toString(),
{ contract }
privateUser,
contract.id + '-closed-at-' + contract.closeTime
)
}
}
// The downside of this approach is if this function goes down for the entire
// day of a multiple of the time period after the market has closed, it won't
// keep sending them notifications bc when it comes back online the time period will have passed
function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) {
if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true
const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } =
marketClosedMultipleOfNDaysAgo(contract)
return (
contract.closeEmailsSent > 0 &&
closedMultipleOfNDaysAgo &&
contract.closeEmailsSent === fullTimePeriodsSinceClose
)
}
function marketClosedMultipleOfNDaysAgo(contract: Contract) {
const now = Date.now()
const closeTime = contract.closeTime
if (!closeTime)
return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 }
const daysSinceClose = Math.floor((now - closeTime) / DAY_MS)
return {
closedMultipleOfNDaysAgo:
daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0,
fullTimePeriodsSinceClose: Math.floor(
daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS
),
}
}

View File

@ -12,6 +12,7 @@ import {
revalidateStaticProps,
} from './utils'
import {
createBadgeAwardedNotification,
createBetFillNotification,
createBettingStreakBonusNotification,
createUniqueBettorBonusNotification,
@ -24,6 +25,7 @@ import {
BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT,
UNIQUE_BETTOR_LIQUIDITY,
} from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import { addHouseSubsidy } from './helpers/add-house-subsidy'
import {
StreakerBadge,
streakerBadgeRarityThresholds,
} from '../../common/badge'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -103,7 +110,7 @@ const updateBettingStreak = async (
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await trans.update(userDoc, {
trans.update(userDoc, {
currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime,
})
@ -143,7 +150,7 @@ const updateBettingStreak = async (
log('message:', result.message)
return
}
if (result.txn)
if (result.txn) {
await createBettingStreakBonusNotification(
user,
result.txn.id,
@ -153,6 +160,8 @@ const updateBettingStreak = async (
newBettingStreak,
eventId
)
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await trans.update(contractDoc, {
trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
return { newUniqueBettorIds }
}
)
if (!newUniqueBettorIds) return
if (oldContract.mechanism === 'cpmm-1') {
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
}
const bonusTxnDetails = {
contractId: oldContract.id,
uniqueNewBettorId: bettor.id,
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn }
})
@ -296,3 +314,39 @@ const notifyFills = async (
const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
}
async function handleBettingStreakBadgeAward(
user: User,
newBettingStreak: number
) {
const alreadyHasBadgeForFirstStreak =
user.achievements?.streaker?.badges.some(
(badge) => badge.data.totalBettingStreak === 1
)
// TODO: check if already awarded 50th streak as well
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
if (streakerBadgeRarityThresholds.includes(newBettingStreak)) {
const badge = {
type: 'STREAKER',
name: 'Streaker',
data: {
totalBettingStreak: newBettingStreak,
},
createdTime: Date.now(),
} as StreakerBadge
// update user
await firestore
.collection('users')
.doc(user.id)
.update({
achievements: {
...user.achievements,
streaker: {
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
},
},
})
await createBadgeAwardedNotification(user, badge)
}
}

View File

@ -1,11 +1,20 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNewContractNotification } from './create-notification'
import { getUser, getValues } from './utils'
import {
createBadgeAwardedNotification,
createNewContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market'
import { User } from '../../common/user'
import * as admin from 'firebase-admin'
import {
MarketCreatorBadge,
marketCreatorBadgeRarityThresholds,
} from '../../common/badge'
export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -28,4 +37,43 @@ export const onCreateContract = functions
richTextToString(desc),
mentioned
)
await handleMarketCreatorBadgeAward(contractCreator)
})
const firestore = admin.firestore()
async function handleMarketCreatorBadgeAward(contractCreator: User) {
// get all contracts by user and calculate size of array
const contracts = await getValues<Contract>(
firestore
.collection(`contracts`)
.where('creatorId', '==', contractCreator.id)
.where('resolution', '!=', 'CANCEL')
)
if (marketCreatorBadgeRarityThresholds.includes(contracts.length)) {
const badge = {
type: 'MARKET_CREATOR',
name: 'Market Creator',
data: {
totalContractsCreated: contracts.length,
},
createdTime: Date.now(),
} as MarketCreatorBadge
// update user
await firestore
.collection('users')
.doc(contractCreator.id)
.update({
achievements: {
...contractCreator.achievements,
marketCreator: {
badges: [
...(contractCreator.achievements?.marketCreator?.badges ?? []),
badge,
],
},
},
})
await createBadgeAwardedNotification(contractCreator, badge)
}
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification'
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy'
@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification(
await createFollowOrMarketSubsidizedNotification(
contract.id,
'liquidity',
'created',

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
followerCountCached: FieldValue.increment(1),
})
await createNotification(
await createFollowOrMarketSubsidizedNotification(
followingUser.id,
'follow',
'created',

View File

@ -1,11 +1,19 @@
import * as functions from 'firebase-functions'
import { getUser, getValues, log } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { getUser, getValues } from './utils'
import {
createBadgeAwardedNotification,
createCommentOrAnswerOrUpdatedContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract'
import { Txn } from '../../common/txn'
import { partition, sortBy } from 'lodash'
import { runTxn, TxnData } from './transact'
import { Bet } from '../../common/bet'
import * as admin from 'firebase-admin'
import { ContractComment } from '../../common/comment'
import { scoreCommentorsAndBettors } from '../../common/scoring'
import {
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
ProvenCorrectBadge,
} from '../../common/badge'
import { GroupContractDoc } from '../../common/group'
export const onUpdateContract = functions.firestore
.document('contracts/{contractId}')
@ -13,18 +21,14 @@ export const onUpdateContract = functions.firestore
const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context
const { openCommentBounties, closeTime, question } = contract
const { closeTime, question } = contract
if (
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
await handleUnusedCommentBountyRefunds(contract)
if (!previousContract.isResolved && contract.isResolved) {
// No need to notify users of resolution, that's handled in resolve-market
return
}
if (
return await handleResolvedContract(contract)
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
await handleContractGroupUpdated(previousContract, contract)
} else if (
previousContract.closeTime !== closeTime ||
previousContract.question !== question
) {
@ -32,6 +36,63 @@ export const onUpdateContract = functions.firestore
}
})
async function handleResolvedContract(contract: Contract) {
if (
(contract.uniqueBettorCount ?? 0) <
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE
)
return
// get all bets on this contract
const bets = await getValues<Bet>(
firestore.collection(`contracts/${contract.id}/bets`)
)
// get comments on this contract
const comments = await getValues<ContractComment>(
firestore.collection(`contracts/${contract.id}/comments`)
)
const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
scoreCommentorsAndBettors(contract, bets, comments)
if (topCommentBetId && profitById[topCommentBetId] > 0) {
// award proven correct badge to user
const comment = commentsById[topCommentId]
const bet = betsById[topCommentBetId]
const user = await getUser(comment.userId)
if (!user) return
const newProvenCorrectBadge = {
createdTime: Date.now(),
type: 'PROVEN_CORRECT',
name: 'Proven Correct',
data: {
contractSlug: contract.slug,
contractCreatorUsername: contract.creatorUsername,
commentId: comment.id,
betAmount: bet.amount,
contractTitle: contract.question,
},
} as ProvenCorrectBadge
// update user
await firestore
.collection('users')
.doc(user.id)
.update({
achievements: {
...user.achievements,
provenCorrect: {
badges: [
...(user.achievements?.provenCorrect?.badges ?? []),
newProvenCorrectBadge,
],
},
},
})
await createBadgeAwardedNotification(user, newProvenCorrectBadge)
}
}
async function handleUpdatedCloseTime(
previousContract: Contract,
contract: Contract,
@ -57,62 +118,42 @@ async function handleUpdatedCloseTime(
)
}
async function handleUnusedCommentBountyRefunds(contract: Contract) {
const outstandingCommentBounties = await getValues<Txn>(
firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY')
)
async function handleContractGroupUpdated(
previousContract: Contract,
contract: Contract
) {
const prevLength = previousContract.groupSlugs?.length ?? 0
const newLength = contract.groupSlugs?.length ?? 0
if (prevLength < newLength) {
// Contract was added to a new group
const groupId = contract.groupLinks?.find(
(link) =>
!previousContract.groupLinks
?.map((l) => l.groupId)
.includes(link.groupId)
)?.groupId
if (!groupId) throw new Error('Could not find new group id')
const commentBountiesOnThisContract = sortBy(
outstandingCommentBounties.filter(
(bounty) => bounty.data?.contractId === contract.id
),
(bounty) => bounty.createdTime
)
await firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
.set({
contractId: contract.id,
createdTime: Date.now(),
} as GroupContractDoc)
}
if (prevLength > newLength) {
// Contract was removed from a group
const groupId = previousContract.groupLinks?.find(
(link) =>
!contract.groupLinks?.map((l) => l.groupId).includes(link.groupId)
)?.groupId
if (!groupId) throw new Error('Could not find old group id')
const [toBank, fromBank] = partition(
commentBountiesOnThisContract,
(bounty) => bounty.toType === 'BANK'
)
if (toBank.length <= fromBank.length) return
await firestore
.collection('contracts')
.doc(contract.id)
.update({ openCommentBounties: 0 })
const refunds = toBank.slice(fromBank.length)
await Promise.all(
refunds.map(async (extraBountyTxn) => {
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: extraBountyTxn.toId,
fromType: 'BANK',
toId: extraBountyTxn.fromId,
toType: 'USER',
amount: extraBountyTxn.amount,
token: 'M$',
category: 'REFUND_COMMENT_BOUNTY',
data: {
contractId: contract.id,
},
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(
`Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`,
result.status
)
log('message:', result.message)
} else {
log(
`Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`,
result.txn?.id
)
}
})
)
await firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
.delete()
}
}
const firestore = admin.firestore()

View File

@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore()
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId)
}
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
})
async function handleUserUpdatedReferral(user: User, eventId: string) {
@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
)
})
}
async function cancelLimitOrders(userId: string) {
const snapshot = (await firestore
.collectionGroup('bets')
.where('userId', '==', userId)
.where('isFilled', '==', false)
.get()) as QuerySnapshot<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import {
BetInfo,
getBinaryCpmmBetInfo,
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({
contractId: z.string(),
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity,
newP,
makers,
ordersToCancel,
} = await (async (): Promise<
BetInfo & {
makers?: maker[]
ordersToCancel?: LimitBet[]
}
> => {
if (
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100
}
const unfilledBetsSnap = await trans.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { unfilledBets, balanceByUserId } =
await getUnfilledBetsAndUserBalances(trans, contractDoc)
return getBinaryCpmmBetInfo(
outcome,
amount,
contract,
limitProb,
unfilledBets
unfilledBets,
balanceByUserId
)
} else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans)
}
if (ordersToCancel) {
for (const bet of ordersToCancel) {
trans.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
}
const balanceChange =
newBet.amount !== 0
? // quick bet
newBet.amount + FLAT_TRADE_FEE
: // limit order
FLAT_TRADE_FEE
trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) })
log('Updated user balance.')
if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update(
contractDoc,
removeUndefinedProps({
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc
.collection('bets')
.where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet>
}
export const getUnfilledBetsAndUserBalances = async (
trans: Transaction,
contractDoc: DocumentReference
) => {
const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc))
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
// Get balance of all users with open limit orders.
const userIds = uniq(unfilledBets.map((bet) => bet.userId))
const userDocs =
userIds.length === 0
? []
: await trans.getAll(
...userIds.map((userId) => firestore.doc(`users/${userId}`))
)
const users = filterDefined(userDocs.map((doc) => doc.data() as User))
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
return { unfilledBets, balanceByUserId }
}
type maker = {
bet: LimitBet
amount: number

View File

@ -9,7 +9,15 @@ import {
RESOLUTIONS,
} from '../../common/contract'
import { Bet } from '../../common/bet'
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
import {
getContractPath,
getUser,
getValues,
isProd,
log,
payUser,
revalidateStaticProps,
} from './utils'
import {
getLoanPayouts,
getPayouts,
@ -145,6 +153,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
resolutions,
collectedFees,
}),
subsidyPool: 0,
}
await contractDoc.update(updatedContract)

View File

@ -0,0 +1,30 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllPrivateUsers } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
if (privateUser.notificationPreferences.badges_awarded === undefined) {
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
badges_awarded: ['browser'],
},
})
}
return
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

@ -0,0 +1,129 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllUsers, getValues } from '../utils'
import { Contract } from 'common/contract'
import {
MarketCreatorBadge,
marketCreatorBadgeRarityThresholds,
StreakerBadge,
streakerBadgeRarityThresholds,
} from 'common/badge'
import { User } from 'common/user'
initAdmin()
const firestore = admin.firestore()
async function main() {
const users = await getAllUsers()
// const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
// const users = filterDefined([await getUser('uglwf3YKOZNGjjEXKc5HampOFRE2')]) // prod David
// const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
await Promise.all(
users.map(async (user) => {
if (!user.id) return
// Only backfill users without achievements
if (user.achievements === undefined) {
await firestore.collection('users').doc(user.id).update({
achievements: {},
})
user.achievements = {}
user.achievements = await awardMarketCreatorBadges(user)
user.achievements = await awardBettingStreakBadges(user)
console.log('Added achievements to user', user.id)
// going to ignore backfilling the proven correct badges for now
}
})
)
}
if (require.main === module) main().then(() => process.exit())
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeErrorBadges(user: User) {
if (
user.achievements.streaker?.badges.some(
(b) => b.data.totalBettingStreak > 1
)
) {
console.log(
`User ${
user.id
} has a streaker badge with streaks ${user.achievements.streaker?.badges.map(
(b) => b.data.totalBettingStreak
)}`
)
// delete non 1,50 streaks
user.achievements.streaker.badges =
user.achievements.streaker.badges.filter((b) =>
streakerBadgeRarityThresholds.includes(b.data.totalBettingStreak)
)
// update user
await firestore.collection('users').doc(user.id).update({
achievements: user.achievements,
})
}
}
async function awardMarketCreatorBadges(user: User) {
// Award market maker badges
const contracts = await getValues<Contract>(
firestore
.collection(`contracts`)
.where('creatorId', '==', user.id)
.where('resolution', '!=', 'CANCEL')
)
const achievements = {
...user.achievements,
marketCreator: {
badges: [...(user.achievements.marketCreator?.badges ?? [])],
},
}
for (const threshold of marketCreatorBadgeRarityThresholds) {
if (contracts.length >= threshold) {
const badge = {
type: 'MARKET_CREATOR',
name: 'Market Creator',
data: {
totalContractsCreated: threshold,
},
createdTime: Date.now(),
} as MarketCreatorBadge
achievements.marketCreator.badges.push(badge)
}
}
// update user
await firestore.collection('users').doc(user.id).update({
achievements,
})
return achievements
}
async function awardBettingStreakBadges(user: User) {
const streak = user.currentBettingStreak ?? 0
const achievements = {
...user.achievements,
streaker: {
badges: [...(user.achievements?.streaker?.badges ?? [])],
},
}
for (const threshold of streakerBadgeRarityThresholds) {
if (streak >= threshold) {
const badge = {
type: 'STREAKER',
name: 'Streaker',
data: {
totalBettingStreak: threshold,
},
createdTime: Date.now(),
} as StreakerBadge
achievements.streaker.badges.push(badge)
}
}
// update user
await firestore.collection('users').doc(user.id).update({
achievements,
})
return achievements
}

View File

@ -50,3 +50,5 @@ async function main() {
}
}
main()
export {}

View File

@ -0,0 +1,65 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'dev.manifold.markets'
// Dev API key for Cause Exploration Prizes (@CEP)
const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
const GROUP_SLUG = 'cart-contest'
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
return await resp.json()
}
async function getMarketsByGroupId(id: string) {
// API structure: /v0/group/by-id/[id]/markets
const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
/* Example curl request:
# Resolve a binary market
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES"}'
*/
async function resolveMarketById(
id: string,
outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
) {
const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
outcome,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug(GROUP_SLUG)
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
console.log(
'Number of resolved markets',
markets.filter((m: any) => m.isResolved).length
)
// Resolve each market to NO
for (const market of markets) {
if (!market.isResolved) {
console.log(`Resolving market ${market.url} to NO`)
await resolveMarketById(market.id, 'NO')
}
}
}
main()
export {}

View File

@ -3,7 +3,6 @@
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = {
doc: DocumentSnapshot
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
return {
doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} as UpdateSpec
}
}
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {

View File

@ -0,0 +1,8 @@
import { initAdmin } from './script-init'
initAdmin()
import { drizzleLiquidity } from '../drizzle-liquidity'
if (require.main === module) {
drizzleLiquidity().then(() => process.exit())
}

View File

@ -1,46 +0,0 @@
import * as admin from 'firebase-admin'
import { uniq } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from '../../../common/contract'
import { parseTags } from '../../../common/util/parse'
import { getValues } from '../utils'
async function updateContractTags() {
const firestore = admin.firestore()
console.log('Updating contracts tags')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loaded', contracts.length, 'contracts')
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
const tags = uniq([
...parseTags(contract.question + contract.description),
...(contract.tags ?? []),
])
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
console.log(
'Updating tags',
contract.slug,
'from',
contract.tags,
'to',
tags
)
await contractRef.update({
tags,
lowercaseTags,
} as Partial<Contract>)
}
}
if (require.main === module) {
updateContractTags().then(() => process.exit())
}

View File

@ -89,17 +89,20 @@ const getGroups = async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
await Promise.all(
groups.map(async (group) => {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get())
.size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
})
})
}
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
updateTotalContractsAndMembers()
// removeUnusedMemberAndContractFields()
}

View File

@ -1,6 +1,7 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils'
import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
@ -29,16 +29,18 @@ 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], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
])
const [
[contractSnap, userSnap],
userBetsSnap,
{ unfilledBets, balanceByUserId },
] = await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
getUnfilledBetsAndUserBalances(transaction, 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
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
unfilledBets,
loanPaid
)
const { newBet, newPool, newP, fees, makers, ordersToCancel } =
getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
unfilledBets,
balanceByUserId,
loanPaid
)
if (
!newP ||
@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
})
)
for (const bet of ordersToCancel) {
transaction.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
return { newBet, makers, maxShares, soldShares }
})

View File

@ -19,8 +19,6 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
@ -61,10 +59,8 @@ addJsonEndpointRoute('/sellbet', sellbet)
addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api'
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
import { isProd } from './utils'
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
// Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here
await sendPortfolioUpdateEmailsToAllUsers()
await sendMarketCloseEmails()
return { success: true }
}

View File

@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 },
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.')
return
}
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id)
@ -31,28 +34,36 @@ export const unsubscribe: EndpointDefinition = {
const previousDestinations =
user.notificationPreferences[notificationSubscriptionType]
let newDestinations = previousDestinations
if (wantsToOptOutAll) newDestinations.push('email')
else
newDestinations = previousDestinations.filter(
(destination) => destination !== 'email'
)
console.log(previousDestinations)
const { email } = user
const update: Partial<PrivateUser> = {
notificationPreferences: {
...user.notificationPreferences,
[notificationSubscriptionType]: previousDestinations.filter(
(destination) => destination !== 'email'
),
[notificationSubscriptionType]: newDestinations,
},
}
await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
res.send(
`
<!DOCTYPE html>
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
if (wantsToOptOutAll) {
res.send(
`
<!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>Manifold Markets 7th Day Anniversary Gift!</title>
<title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
@ -163,19 +174,6 @@ export const unsubscribe: EndpointDefinition = {
</a>
</td>
</tr>
<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;">
Hello!</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
@ -186,20 +184,9 @@ export const unsubscribe: EndpointDefinition = {
data-testid="4XoHRGw1Y">
<span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to:
${email} has opted out of receiving unnecessary email notifications
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
@ -219,9 +206,193 @@ export const unsubscribe: EndpointDefinition = {
</div>
</div>
</body>
</html>`
)
} else {
res.send(
`
<!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>Unsubscribe from Manifold Markets emails</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%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
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]-->
<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="background-color:#F4F4F4;">
<!--[if mso | IE]><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:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top: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 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>
<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;">
Hello!</span></p>
</div>
</td>
</tr>
<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;">
${email} has been unsubscribed from email notifications related to:
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
`
)
)
}
},
}

View File

@ -1,10 +1,11 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { groupBy, keyBy, sortBy } from 'lodash'
import fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans'
@ -14,18 +15,44 @@ import {
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
calculateMetricsByContract,
computeElasticity,
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise'
import { newEndpointNoAuth } from './api'
import { getFunctionUrl } from '../../common/api'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
export const scheduleUpdateMetrics = functions.pubsub
.schedule('every 15 minutes')
.onRun(async () => {
const url = getFunctionUrl('updatemetrics')
console.log('Scheduling update metrics', url)
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({}),
})
export const updateMetrics = functions
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
const json = await response.json()
if (response.ok) console.log(json)
else console.error(json)
})
export const updatemetrics = newEndpointNoAuth(
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
async (_req) => {
await updateMetricsCore()
return { success: true }
}
)
export async function updateMetricsCore() {
console.log('Loading users')
@ -35,11 +62,7 @@ export async function updateMetricsCore() {
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>(
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
)
const userPortfolioHistory = await loadPortfolioHistory(users)
console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups'))
@ -103,6 +126,7 @@ export async function updateMetricsCore() {
fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
elasticity: computeElasticity(contractBets, contract),
...cpmmFields,
},
}
@ -115,11 +139,10 @@ export async function updateMetricsCore() {
)
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const portfolioHistory = userPortfolioHistory[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? []
const newCreatorVolume = calculateCreatorVolume(userContracts)
const newPortfolio = calculateNewPortfolioMetrics(
@ -127,21 +150,51 @@ export async function updateMetricsCore() {
contractsById,
currentBets
)
const lastPortfolio = last(portfolioHistory)
const currPortfolio = portfolioHistory.current
const didPortfolioChange =
lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
currPortfolio === undefined ||
currPortfolio.balance !== newPortfolio.balance ||
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
currPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const metricsByContract = calculateMetricsByContract(
currentBets,
contractsById
)
const contractRatios = userContracts
.map((contract) => {
if (
!contract.flaggedByUsernames ||
contract.flaggedByUsernames?.length === 0
) {
return 0
}
const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
return contractRatio
})
.filter((ratio) => ratio > 0)
const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
)
let newFractionResolvedCorrectly = 1
if (userContracts.length > 0) {
newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length
}
return {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
metricsByContract,
}
})
@ -157,61 +210,61 @@ export async function updateMetricsCore() {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
},
},
subcollectionUpdates: {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: didPortfolioChange ? newPortfolio : {},
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
}
}
)
await writeAsync(
firestore,
userUpdates.map((u) => u.fieldUpdates)
await writeAsync(firestore, userUpdates)
const portfolioHistoryUpdates = filterDefined(
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: newPortfolio,
}
: null
})
)
await writeAsync(
firestore,
userUpdates
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
.map((u) => u.subcollectionUpdates),
'set'
await writeAsync(firestore, portfolioHistoryUpdates, 'set')
const contractMetricsUpdates = userMetrics.flatMap(
({ user, metricsByContract }) => {
const collection = firestore
.collection('users')
.doc(user.id)
.collection('contract-metrics')
return metricsByContract.map((metrics) => ({
doc: collection.doc(metrics.contractId),
fields: metrics,
}))
}
)
await writeAsync(firestore, contractMetricsUpdates, 'set')
log(`Updated metrics for ${users.length} users.`)
try {
const groupUpdates = groups.map((group, index) => {
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
const groupContracts = groupContractIds
.map((e) => contractsById[e.contractId])
.filter((e) => e !== undefined) as Contract[]
const bets = groupContracts.map((e) => {
if (e != null && e.id in betsByContract) {
return betsByContract[e.id] ?? []
} else {
return []
}
})
const groupContracts = filterDefined(
groupContractIds.map((e) => contractsById[e.contractId])
)
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
const creatorScores = scoreCreators(groupContracts)
const traderScores = scoreTraders(groupContracts, bets)
@ -243,3 +296,46 @@ const topUserScores = (scores: { [userId: string]: number }) => {
}
type GroupContractDoc = { contractId: string; createdTime: number }
const BAD_RESOLUTION_THRESHOLD = 0.1
const loadPortfolioHistory = async (users: User[]) => {
const now = Date.now()
const userPortfolioHistory = await batchedWaitAll(
users.map((user) => async () => {
const query = firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.orderBy('timestamp', 'desc')
.limit(1)
const portfolioMetrics = await Promise.all([
getValues<PortfolioMetrics>(query),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 7 * DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 30 * DAY_MS)
),
])
const [current, day, week, month] = portfolioMetrics.map(
(p) => p[0] as PortfolioMetrics | undefined
)
return {
userId: user.id,
current,
day,
week,
month,
}
}),
100
)
return keyBy(userPortfolioHistory, (p) => p.userId)
}

View File

@ -47,7 +47,7 @@ export const writeAsync = async (
const batch = db.batch()
for (const { doc, fields } of chunks[i]) {
if (operationType === 'update') {
batch.update(doc, fields)
batch.update(doc, fields as any)
} else {
batch.set(doc, fields)
}
@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
return users.docs.map((doc) => doc.data() as PrivateUser)
}
export const getAllUsers = async () => {
const firestore = admin.firestore()
const users = await firestore.collection('users').get()
return users.docs.map((doc) => doc.data() as User)
}
export const getUserByUsername = async (username: string) => {
const firestore = admin.firestore()
const snap = await firestore

View File

@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import {
getAllPrivateUsers,
getGroup,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS } from '../../common/util/time'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow'
import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1')
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19-20 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers()
@ -40,20 +43,30 @@ export async function getTrendingContracts() {
)
}
async function sendTrendingMarketsEmailsToAllUsers() {
export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6
const privateUsers = isProd()
? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails
: filterDefined([
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
])
const privateUsersToSendEmailsTo = privateUsers
.filter((user) => {
return (
// Get all users that haven't unsubscribed from weekly emails
.filter(
(user) =>
user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent
)
})
.slice(150) // Send the emails out in batches
)
.slice(0, 90) // Send the emails out in batches
// For testing different users on prod: (only send ian an email though)
// const privateUsersToSendEmailsTo = filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
// // isProd()
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
log(
'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length,
@ -70,42 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e')
)
.slice(0, 20)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
.slice(0, 50)
// TODO: convert to Promise.all
for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
continue
}
const contractsAvailableToSend = trendingContracts.filter((contract) => {
return !contract.uniqueBettorIds?.includes(privateUser.id)
})
if (contractsAvailableToSend.length < numContractsToSend) {
log('not enough new, unbet-on contracts to send to user', privateUser.id)
await firestore.collection('private-users').doc(privateUser.id).update({
const uniqueTrendingContracts = removeSimilarQuestions(
trendingContracts,
trendingContracts,
true
).slice(0, 20)
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
return
}
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
privateUser.id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
privateUser.id,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets(
privateUser.id,
unBetOnGroupMarkets
)
const marketsAvailableToSend = uniqBy(
[
...chooseRandomSubset(unbetOnFollowedMarkets, 2),
// // Most people will belong to groups but may not follow other users,
// so choose more from the other subsets if the followed markets is sparse
...chooseRandomSubset(
unBetOnGroupMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
...chooseRandomSubset(
similarBettorsMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
],
(contract) => contract.id
)
// // at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend) {
const trendingMarketsToSend =
numContractsToSend - marketsAvailableToSend.length
log(
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
)
marketsAvailableToSend.push(
...removeSimilarQuestions(
uniqueTrendingContracts,
marketsAvailableToSend,
false
)
.filter(
(contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
)
.slice(0, trendingMarketsToSend)
)
}
if (marketsAvailableToSend.length < numContractsToSend) {
log(
'not enough new, unbet-on contracts to send to user',
privateUser.id
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyTrendingEmailSent: true,
})
return
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
marketsAvailableToSend,
numContractsToSend
)
const user = await getUser(privateUser.id)
if (!user) return
log(
'sending contracts:',
contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
)
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
continue
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
contractsAvailableToSend,
numContractsToSend
)
const user = await getUser(privateUser.id)
if (!user) continue
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
}
)
}
const MINIMUM_POPULARITY_SCORE = 10
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows')
)
const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('visibility', '==', 'public')
.where('creatorId', '==', follow.userId)
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
.orderBy('popularityScore', 'desc')
.limit(50)
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts.filter(
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = uniqBy(
unBetOnContractsFromFollows.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
// log(
// 'top 10 sorted markets by followed users',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
const getUserUnBetOnGroupsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
const snap = await firestore
.collectionGroup('groupMembers')
.where('userId', '==', userId)
.get()
const groupIds = filterDefined(
snap.docs.map((doc) => doc.ref.parent.parent?.id)
)
const groups = filterDefined(
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
)
if (groups.length === 0) return []
const unBetOnContractsFromGroups = await Promise.all(
groups.map(async (group) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('visibility', '==', 'public')
.where('groupSlugs', 'array-contains', group.slug)
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
.orderBy('popularityScore', 'desc')
.limit(50)
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts.filter(
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = uniqBy(
unBetOnContractsFromGroups.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
const topSortedMarkets = removeSimilarQuestions(
uniqueSortedMarkets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted group markets',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
// Gets markets followed by similar bettors and bet on by similar bettors
const getSimilarBettorsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
// get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues<Contract>(
firestore
.collection('contracts')
.where('uniqueBettorIds', 'array-contains', userId)
)
if (contractsUserHasBetOn.length === 0) return []
// count the number of times each unique bettor id appears on those contracts
const bettorIdsToCounts = countBy(
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
(bettorId) => bettorId
)
// sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object.entries(bettorIdsToCounts)
.sort((a, b) => b[1] - a[1])
.filter((bettorId) => bettorId[1] > 2)
.map((entry) => entry[0])
.filter((bettorId) => bettorId !== userId)
// get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds.slice(0, 10)
if (similarBettorIds.length === 0) return []
// get contracts with unique bettor ids with this user
const contractsSimilarBettorsHaveBetOn = uniqBy(
(
await getValues<Contract>(
firestore
.collection('contracts')
.where(
'uniqueBettorIds',
'array-contains-any',
similarBettorIds.slice(0, 10)
)
.orderBy('popularityScore', 'desc')
.limit(200)
)
).filter(
(contract) =>
!contract.uniqueBettorIds?.includes(userId) &&
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
),
(contract) => contract.id
)
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
.map((contract) => {
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
similarBettorIds.includes(bettorId)
).length
return [contract, appearances] as [Contract, number]
})
.sort((a, b) => b[1] - a[1])
.map((entry) => entry[0])
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
sortedContractsInSimilarBettorsBets,
sortedContractsInSimilarBettorsBets,
true
)
const topMostSimilarContracts = removeSimilarQuestions(
uniqueSortedContractsInSimilarBettorsBets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted contracts other similar bettors have bet on',
// topMostSimilarContracts.map((c) => c.question)
// )
return topMostSimilarContracts
}
// search contract array by question and remove contracts with 3 matching words in the question
const removeSimilarQuestions = (
contractsToFilter: Contract[],
byContracts: Contract[],
allowExactSameContracts: boolean
) => {
// log(
// 'contracts to filter by',
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
// )
let contractsToRemove: Contract[] = []
byContracts.length > 0 &&
byContracts.forEach((contract) => {
const contractQuestion = stripNonAlphaChars(
contract.question.toLowerCase()
)
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
(w) => !IGNORE_WORDS.includes(w)
)
contractsToRemove = contractsToRemove.concat(
contractsToFilter.filter(
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
(c2) => {
const significantOverlap =
// TODO: we should probably use a library for comparing strings/sentiments
uniq(
stripNonAlphaChars(c2.question.toLowerCase()).split(' ')
).filter((word) => contractQuestionWords.includes(word)).length >
2
const lessPopular =
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
return (
(significantOverlap && lessPopular) ||
(allowExactSameContracts ? false : c2.id === contract.id)
)
}
)
)
})
// log(
// 'contracts to filter out',
// contractsToRemove.map((c) => c.question)
// )
const returnContracts = contractsToFilter.filter(
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
)
return returnContracts
}
const fiveMinutes = 5 * 60 * 1000
@ -116,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng)
return contracts.slice(0, count)
}
function stripNonAlphaChars(str: string) {
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
}
const IGNORE_WORDS = [
'the',
'a',
'an',
'and',
'or',
'of',
'to',
'in',
'on',
'will',
'be',
'is',
'are',
'for',
'by',
'at',
'from',
'what',
'when',
'which',
'that',
'it',
'as',
'if',
'then',
'than',
'but',
'have',
'has',
'had',
]

View File

@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
)
)
)
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id)
// Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
return await setEmailFlagAsSent(privateUser.id)
const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id)
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
(differences) => Math.abs(differences.profit)
).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.profit) > 1
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
usersToContractsCreated[privateUser.id].length === 0
) {
log(
'No bets in last week, no market movers, no markets created. Not sending an email.'
`No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
return
return await setEmailFlagAsSent(privateUser.id)
}
// Set the flag beforehand just to be safe
await setEmailFlagAsSent(privateUser.id)
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 users:', count)
})
)
}
async function setEmailFlagAsSent(privateUserId: string) {
await firestore.collection('private-users').doc(privateUserId).update({
weeklyPortfolioUpdateEmailSent: true,
})
}
export type PerContractInvestmentsData = {
questionTitle: string
questionUrl: string

View File

@ -1,121 +0,0 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { subtractObjects } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { noFees } from '../../common/fees'
import { APIError, newEndpoint, validate } from './api'
import { redeemShares } from './redeem-shares'
const bodySchema = z.object({
contractId: z.string(),
})
export const withdrawliquidity = newEndpoint({}, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
return await firestore
.runTransaction(async (trans) => {
const lpDoc = firestore.doc(`users/${auth.uid}`)
const lpSnap = await trans.get(lpDoc)
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
const lp = lpSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as CPMMContract
const liquidityCollection = firestore.collection(
`contracts/${contractId}/liquidity`
)
const liquiditiesSnap = await trans.get(liquidityCollection)
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const userShares = getUserLiquidityShares(
auth.uid,
contract,
liquidities,
true
)
// zero all added amounts for now
// can add support for partial withdrawals in the future
liquiditiesSnap.docs
.filter(
(_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid
)
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
const payout = Math.min(...Object.values(userShares))
if (payout <= 0) return {}
const newBalance = lp.balance + payout
const newTotalDeposits = lp.totalDeposits + payout
trans.update(lpDoc, {
balance: newBalance,
totalDeposits: newTotalDeposits,
} as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares)
const minPoolShares = Math.min(...Object.values(newPool))
const adjustedTotal = contract.totalLiquidity - payout
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
trans.update(contractDoc, {
pool: newPool,
totalLiquidity: newTotalLiquidity,
})
const prob = getProbability(contract)
// surplus shares become user's bets
const bets = Object.entries(userShares)
.map(([outcome, shares]) =>
shares - payout < 1 // don't create bet if less than 1 share
? undefined
: ({
userId: auth.uid,
contractId: contract.id,
amount:
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
shares: shares - payout,
outcome,
probBefore: prob,
probAfter: prob,
createdTime: Date.now(),
isLiquidityProvision: true,
fees: noFees,
} as Omit<Bet, 'id'>)
)
.filter((x) => x !== undefined)
for (const bet of bets) {
const doc = firestore.collection(`contracts/${contract.id}/bets`).doc()
trans.create(doc, { id: doc.id, ...bet })
}
return userShares
})
.then(async (result) => {
// redeem surplus bet with pre-existing bets
await redeemShares(auth.uid, contractId)
console.log('userid', auth.uid, 'withdraws', result)
return result
})
})
const firestore = admin.firestore()

View File

@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) {
numericValue,
resolution,
} = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<meta charset="utf-8">
<title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script>
</head>
<style>
${getTemplateCss(theme, fontSize)}
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
</div>
<div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-tight">
${truncatedQuestion}
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
${question}
</div>
<div class="flex flex-col">
${
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<!-- Metadata -->
<div class="absolute bottom-16">
<div class="text-gray-500 text-3xl max-w-[80vw]">
<div class="text-gray-500 text-3xl max-w-[80vw] line-clamp-2">
${metadata}
</div>
</div>

View File

@ -24,8 +24,5 @@
"prettier": "2.7.1",
"ts-node": "10.9.1",
"typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
}
}

10
web/components/NoSEO.tsx Normal file
View File

@ -0,0 +1,10 @@
import Head from 'next/head'
/** Exclude page from search results */
export function NoSEO() {
return (
<Head>
<meta name="robots" content="noindex,follow" />
</Head>
)
}

View File

@ -15,7 +15,7 @@ export function SEO(props: {
return (
<Head>
<title>{title} | Manifold Markets</title>
<title>{`${title} | Manifold Markets`}</title>
<meta
property="og:title"

View File

@ -1,84 +0,0 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { FundsSelector } from './yes-no-selector'
export function AddFundsButton(props: { className?: string }) {
const { className } = props
const user = useUser()
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
2500
)
const location = useLocation()
return (
<>
<label
htmlFor="add-funds"
className={clsx(
'btn btn-xs btn-outline modal-button font-normal normal-case',
className
)}
>
Get M$
</label>
<input type="checkbox" id="add-funds" className="modal-toggle" />
<div className="modal">
<div className="modal-box">
<div className="mb-6 text-xl">Get Mana</div>
<div className="mb-6 text-gray-500">
Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
<FundsSelector
selected={amountSelected}
onSelect={setAmountSelected}
/>
<div className="mt-6">
<div className="mb-1 text-sm text-gray-500">Price USD</div>
<div className="text-xl">
${Math.round(amountSelected / 100)}.00
</div>
</div>
<div className="modal-action">
<label htmlFor="add-funds" className={clsx('btn btn-ghost')}>
Back
</label>
<form
action={checkoutURL(user?.id || '', amountSelected, location)}
method="POST"
>
<button
type="submit"
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
>
Checkout
</button>
</form>
</div>
</div>
</div>
</>
)
}
// needed in next js
// window not loaded at runtime
const useLocation = () => {
const [href, setHref] = useState('')
useEffect(() => {
setHref(window.location.href)
}, [])
return href
}

View File

@ -0,0 +1,58 @@
import { manaToUSD } from 'common/util/format'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { Button } from './button'
import { Modal } from './layout/modal'
import { FundsSelector } from './yes-no-selector'
export function AddFundsModal(props: {
open: boolean
setOpen(open: boolean): void
}) {
const { open, setOpen } = props
const user = useUser()
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
2500
)
return (
<Modal open={open} setOpen={setOpen} className="rounded-md bg-white p-8">
<div className="mb-6 text-xl text-indigo-700">Get Mana</div>
<div className="mb-6 text-gray-700">
Buy mana (M$) to trade in your favorite markets. <br /> (Not redeemable
for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
<FundsSelector selected={amountSelected} onSelect={setAmountSelected} />
<div className="mt-6">
<div className="mb-1 text-sm text-gray-500">Price USD</div>
<div className="text-xl">{manaToUSD(amountSelected)}</div>
</div>
<div className="modal-action">
<Button color="gray-white" onClick={() => setOpen(false)}>
Back
</Button>
<form
action={checkoutURL(
user?.id || '',
amountSelected,
window.location.href
)}
method="POST"
>
<Button type="submit" color="gradient">
Checkout
</Button>
</form>
</div>
</Modal>
)
}

View File

@ -1,11 +1,12 @@
import clsx from 'clsx'
import React from 'react'
import React, { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
export function AmountInput(props: {
amount: number | undefined
@ -35,6 +36,8 @@ export function AmountInput(props: {
onChange(isInvalid ? undefined : amount)
}
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
return (
<>
<Col className={className}>
@ -42,9 +45,9 @@ export function AmountInput(props: {
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label}
</span>
<input
<Input
className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
'pl-9',
error && 'input-error',
'w-24 md:w-auto',
inputClassName
@ -66,9 +69,16 @@ export function AmountInput(props: {
{error === 'Insufficient balance' ? (
<>
Not enough funds.
<span className="ml-1 text-indigo-500">
<SiteLink href="/add-funds">Buy more?</SiteLink>
</span>
<button
className="ml-1 text-indigo-500 hover:underline hover:decoration-indigo-400"
onClick={() => setAddFundsModalOpen(true)}
>
Buy more?
</button>
<AddFundsModal
open={addFundsModalOpen}
setOpen={setAddFundsModalOpen}
/>
</>
) : (
error

View File

@ -192,6 +192,7 @@ export function AnswerBetPanel(props: {
isSubmitting={isSubmitting}
disabled={!!betDisabled}
color={'indigo'}
actionLabel="Buy"
/>
) : (
<BetSignUpPrompt />

View File

@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Linkify } from '../linkify'
import { Input } from '../input'
export function AnswerItem(props: {
answer: Answer
@ -74,8 +75,8 @@ export function AnswerItem(props: {
<Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo &&
(showChoice === 'checkbox' ? (
<input
className="input input-bordered w-24 justify-self-end text-2xl"
<Input
className="w-24 justify-self-end !text-2xl"
type="number"
placeholder={`${roundedProb}`}
maxLength={9}

View File

@ -20,11 +20,11 @@ import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CATEGORY_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice'
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
@ -38,6 +38,7 @@ export function AnswersPanel(props: {
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
)
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
const [winningAnswers, losingAnswers] = partition(
@ -104,6 +105,10 @@ export function AnswersPanel(props: {
? 'checkbox'
: undefined
const colorSortedAnswer = useChartAnswers(contract).map(
(value, _index) => value.text
)
return (
<Col className="gap-3">
{(resolveOption || resolution) &&
@ -128,7 +133,12 @@ export function AnswersPanel(props: {
)}
>
{answerItems.map((item) => (
<OpenAnswer key={item.id} answer={item} contract={contract} />
<OpenAnswer
key={item.id}
answer={item}
contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
/>
))}
{hasZeroBetAnswers && !showAllAnswers && (
<Button
@ -147,11 +157,9 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div>
)}
{outcomeType === 'FREE_RESPONSE' &&
tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} />
)}
{outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
<CreateAnswerPanel contract={contract} />
)}
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
!resolution && (
@ -174,15 +182,18 @@ export function AnswersPanel(props: {
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
colorIndex: number | undefined
}) {
const { answer, contract } = props
const { username, avatarUrl, name, text } = answer
const { answer, contract, colorIndex } = props
const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
return (
<Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
<Col className="my-1 px-2">
<Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel
answer={answer}
@ -193,40 +204,44 @@ function OpenAnswer(props: {
/>
</Modal>
<div
className="pointer-events-none absolute -mx-2 h-full rounded-tl-md bg-green-600 bg-opacity-10"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
<Row className="my-4 gap-3">
<Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<Linkify className="whitespace-pre-line text-lg" text={text} />
<Row className="align-items items-center justify-end gap-4">
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
<Col
className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)}
>
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row>
<Avatar
className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
username={username}
avatarUrl={avatarUrl}
/>
<Linkify
className="text-md cursor-pointer whitespace-pre-line"
text={text}
/>
</Row>
<Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div>
{tradingAllowed(contract) && (
<Button
size="2xs"
color="gray-outline"
onClick={() => setOpen(true)}
/>
</Row>
</Col>
</Col>
</Row>
className="my-auto"
>
BUY
</Button>
)}
</Row>
</Row>
<hr
color={color}
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
</Col>
</Col>
)
}

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract'
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash'
import { Button } from '../button'
import { ExpandingInput } from '../expanding-input'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div>
<Textarea
<ExpandingInput
value={text}
onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
className="w-full"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,8 +1,8 @@
import { MAX_ANSWER_LENGTH } from 'common/answer'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { ExpandingInput } from '../expanding-input'
export function MultipleChoiceAnswers(props: {
answers: string[]
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
{answers.map((answer, i) => (
<Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '}
<Textarea
<ExpandingInput
value={answer}
onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none"
className="ml-2 w-full"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,13 +1,13 @@
import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
import { XCircleIcon } from '@heroicons/react/outline'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'

View File

@ -68,11 +68,11 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser])
useEffect(() => {
if (authUser != null) {
if (authUser) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
} else if (authUser === null) {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])

View File

@ -0,0 +1,62 @@
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { getBadgesByRarity } from 'common/badge'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { BadgesModal } from 'web/components/profile/badges-modal'
import { ParsedUrlQuery } from 'querystring'
export const goldClassName = 'text-amber-400'
export const silverClassName = 'text-gray-500'
export const bronzeClassName = 'text-amber-900'
export function BadgeDisplay(props: {
user: User | undefined | null
query: ParsedUrlQuery
}) {
const { user, query } = props
const [showBadgesModal, setShowBadgesModal] = useState(false)
useEffect(() => {
const showBadgesModal = query['show'] == 'badges'
setShowBadgesModal(showBadgesModal)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// get number of badges of each rarity type
const badgesByRarity = getBadgesByRarity(user)
const badgesByRarityItems = Object.entries(badgesByRarity).map(
([rarity, numBadges]) => {
return (
<Row
key={rarity}
className={clsx(
'items-center gap-2',
rarity === 'bronze'
? bronzeClassName
: rarity === 'silver'
? silverClassName
: goldClassName
)}
>
<span className={clsx('-m-0.5 text-lg')}></span>
<span className="text-xs">{numBadges}</span>
</Row>
)
}
)
return (
<Row
className={'cursor-pointer gap-2'}
onClick={() => setShowBadgesModal(true)}
>
{badgesByRarityItems}
{user && (
<BadgesModal
isOpen={showBadgesModal}
setOpen={setShowBadgesModal}
user={user}
/>
)}
</Row>
)
}

View File

@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
user: User
}) {
const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<>
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
contract={contract as CPMMBinaryContract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
mobileView={true}
/>
</Col>

View File

@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
import { Button } from './button'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
@ -34,14 +34,17 @@ export function BetInline(props: {
const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
amount ?? 0,
contract,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])

View File

@ -25,7 +25,7 @@ import {
NoLabel,
YesLabel,
} from './outcome-label'
import { getProbability } from 'common/calculate'
import { getContractBetMetrics, getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
@ -55,7 +55,9 @@ export function BetPanel(props: {
const { contract, className } = props
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -86,12 +88,14 @@ export function BetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
</>
) : (
@ -117,7 +121,9 @@ export function SimpleBetPanel(props: {
const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<Col className={className}>
@ -142,6 +148,7 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess}
/>
<LimitOrderPanel
@ -149,6 +156,7 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess}
/>
@ -167,13 +175,21 @@ export function SimpleBetPanel(props: {
export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean
onBuySuccess?: () => void
mobileView?: boolean
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
props
const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
mobileView,
} = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -261,7 +277,8 @@ export function BuyPanel(props: {
betAmount ?? 0,
contract,
undefined,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const [seeLimit, setSeeLimit] = useState(false)
@ -395,6 +412,7 @@ export function BuyPanel(props: {
disabled={!!betDisabled || outcome === undefined}
size="xl"
color={outcome === 'NO' ? 'red' : 'green'}
actionLabel="Wager"
/>
)}
<button
@ -415,6 +433,7 @@ export function BuyPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
<LimitBets
contract={contract}
@ -430,11 +449,19 @@ export function BuyPanel(props: {
function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
} = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -580,7 +607,8 @@ function LimitOrderPanel(props: {
yesAmount,
contract,
yesLimitProb ?? initialProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const yesReturnPercent = formatPercent(yesReturn)
@ -594,7 +622,8 @@ function LimitOrderPanel(props: {
noAmount,
contract,
noLimitProb ?? initialProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const noReturnPercent = formatPercent(noReturn)
@ -829,15 +858,25 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const betDisabled = isSubmitting || !amount || error
const betDisabled = isSubmitting || !amount || error !== undefined
// Sell all shares if remaining shares would be < 1
const isSellingAllShares = amount === Math.floor(shares)
const sellQuantity = isSellingAllShares ? shares : amount
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const soldShares = Math.min(sellQuantity ?? 0, shares)
const saleFrac = soldShares / shares
const loanPaid = saleFrac * loanAmount
const { invested } = getContractBetMetrics(contract, userBets)
const costBasis = invested * saleFrac
async function submitSell() {
if (!user || !amount) return
@ -880,10 +919,26 @@ export function SellPanel(props: {
contract,
sellQuantity ?? 0,
sharesOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const getValue = getMappedValue(contract)
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
const displayedDifference =
contract.outcomeType === 'PSEUDO_NUMERIC'
? formatLargeNumber(rawDifference)
: formatPercent(rawDifference)
const probChange = Math.abs(resultProb - initialProb)
const warning =
probChange >= 0.3
? `Are you sure you want to move the market by ${displayedDifference}?`
: undefined
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = partition(
openUserBets,
@ -923,14 +978,18 @@ export function SellPanel(props: {
label="Qty"
error={error}
disabled={isSubmitting}
inputClassName="w-full"
inputClassName="w-full ml-1"
/>
<Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds
Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Profit
<span className="text-neutral">{formatMoney(profit)}</span>
</Row>
<Row className="items-center justify-between">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
@ -941,24 +1000,33 @@ export function SellPanel(props: {
{format(resultProb)}
</div>
</Row>
{loanPaid !== 0 && (
<>
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
Loan payment
<span className="text-neutral">{formatMoney(-loanPaid)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds
<span className="text-neutral">{formatMoney(netProceeds)}</span>
</Row>
</>
)}
</Col>
<Spacer h={8} />
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: sharesOutcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitSell}
>
{isSubmitting ? 'Submitting...' : 'Submit sell'}
</button>
<WarningConfirmationButton
marketType="binary"
amount={undefined}
warning={warning}
isSubmitting={isSubmitting}
onSubmit={betDisabled ? undefined : submitSell}
disabled={!!betDisabled}
size="xl"
color="blue"
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
/>
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
</>

View File

@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
import {
formatMoney,
@ -17,6 +17,7 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
MAX_USER_BET_CONTRACTS_LOADED,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api'
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
@ -50,6 +51,7 @@ import {
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -80,6 +82,10 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const loadedPartialData =
userBets?.length === MAX_USER_BETS_LOADED ||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort',
store: storageStore(safeLocalStorage()),
@ -160,26 +166,38 @@ export function BetsList(props: { user: User }) {
unsettled,
(c) => contractsMetrics[c.id].payout
)
const currentNetInvestment = sumBy(
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col>
<Row className="justify-between gap-4 sm:flex-row">
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentNetInvestment)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
{loadedPartialData && (
<Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4">
<ExclamationIcon className="h-5 w-5" />
<div>Partial trade data only</div>
</Row>
)}
<Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4">
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentBetsValue)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Total loans
</div>
<div className="text-lg">{formatMoney(currentLoan)}</div>
</Col>
</Row>
<Row className="gap-2">
<select
@ -206,7 +224,7 @@ export function BetsList(props: { user: User }) {
<option value="closeTime">Close date</option>
</select>
</Row>
</Row>
</Col>
<Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? (
@ -407,7 +425,9 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<div className="overflow-x-auto">
@ -456,6 +476,7 @@ export function ContractBetsTable(props: {
contract={contract}
isYourBet={isYourBets}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
))}
</tbody>
@ -470,8 +491,10 @@ function BetRow(props: {
saleBet?: Bet
isYourBet: boolean
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) {
const { bet, saleBet, contract, isYourBet, unfilledBets } = props
const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
props
const {
amount,
outcome,
@ -499,9 +522,9 @@ function BetRow(props: {
} else if (contract.isResolved) {
return resolvedPayout(contract, bet)
} else {
return calculateSaleAmount(contract, bet, unfilledBets)
return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
}
}, [contract, bet, saleBet, unfilledBets])
}, [contract, bet, saleBet, unfilledBets, balanceByUserId])
const saleDisplay = isAnte ? (
'ANTE'
@ -540,6 +563,7 @@ function BetRow(props: {
contract={contract}
bet={bet}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
)}
</td>
@ -585,8 +609,9 @@ function SellButton(props: {
contract: Contract
bet: Bet
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) {
const { contract, bet, unfilledBets } = props
const { contract, bet, unfilledBets, balanceByUserId } = props
const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false)
@ -600,10 +625,16 @@ function SellButton(props: {
contract,
outcome,
shares,
unfilledBets
unfilledBets,
balanceByUserId
)
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets)
const saleAmount = calculateSaleAmount(
contract,
bet,
unfilledBets,
balanceByUserId
)
const profit = saleAmount - bet.amount
return (
@ -612,7 +643,7 @@ function SellButton(props: {
label: 'Sell',
disabled: isSubmitting,
}}
submitBtn={{ className: 'btn-primary', label: 'Sell' }}
submitBtn={{ label: 'Sell', color: 'green' }}
onSubmit={async () => {
setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id })

View File

@ -1,5 +1,6 @@
import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx'
import { LoadingIndicator } from 'web/components/loading-indicator'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type ColorType =
@ -9,18 +10,57 @@ export type ColorType =
| 'indigo'
| 'yellow'
| 'gray'
| 'gray-outline'
| 'gradient'
| 'gray-white'
| 'highlight-blue'
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}
export function buttonClass(size: SizeType, color: ColorType | 'override') {
return clsx(
'font-md inline-flex items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
)
}
export function Button(props: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
color?: ColorType
color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
}) {
const {
children,
@ -30,47 +70,17 @@ export function Button(props: {
color = 'indigo',
type = 'button',
disabled = false,
loading,
} = props
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return (
<button
type={type}
className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses,
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)}
disabled={disabled}
className={clsx(buttonClass(size, color), className)}
disabled={disabled || loading}
onClick={onClick}
>
{loading && <LoadingIndicator className={'mr-2 border-gray-500'} />}
{children}
</button>
)

16
web/components/card.tsx Normal file
View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
export function Card(props: JSX.IntrinsicElements['div']) {
const { children, className, ...rest } = props
return (
<div
className={clsx(
'cursor-pointer rounded-lg border bg-white transition-shadow hover:shadow-md focus:shadow-md',
className
)}
{...rest}
>
{children}
</div>
)
}

View File

@ -1,8 +1,7 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { SwitchVerticalIcon } from '@heroicons/react/outline'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
@ -16,16 +15,16 @@ import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = {
amount: number
@ -154,9 +153,9 @@ function CreateChallengeForm(props: {
{contract ? (
<span className="underline">{contract.question}</span>
) : (
<Textarea
<ExpandingInput
placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none"
className="mt-1 w-full"
autoFocus={true}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}
@ -302,16 +301,7 @@ function CreateChallengeForm(props: {
<Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div>
<button
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<CopyLinkButton url={challengeSlug} />
<QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}>

View File

@ -6,6 +6,8 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
import { Card } from '../card'
export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity } = props
@ -15,43 +17,44 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
const raised = sumBy(txns, (txn) => txn.amount)
return (
<Link href={`/charity/${slug}`} passHref>
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md">
<Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />}
</Row>
<div className="px-8">
<figure className="relative h-32">
{photo ? (
<Image src={photo} alt="" layout="fill" objectFit="contain" />
) : (
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
)}
</figure>
</div>
<div className="card-body">
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
raised
</Row>
{/* {match && (
<Link href={`/charity/${slug}`}>
<a className="flex-1">
<Card className="!rounded-2xl">
<Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />}
</Row>
<div className="px-8">
<figure className="relative h-32">
{photo ? (
<Image src={photo} alt="" layout="fill" objectFit="contain" />
) : (
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
)}
</figure>
</div>
<Col className="p-8">
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
raised
</Row>
{/* {match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)} */}
</Row>
</>
)}
</div>
</div>
</Row>
</>
)}
</Col>
</Card>
</a>
</Link>
)
}

View File

@ -9,8 +9,6 @@ import { BinaryContract } from 'common/contract'
import { DAY_MS } from 'common/util/time'
import {
TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
@ -20,6 +18,10 @@ import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime),
@ -29,9 +31,9 @@ const getBetPoints = (bets: Bet[]) => {
}
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const { data, x, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
@ -73,14 +75,15 @@ export const BinaryContractChart = (props: {
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
yKind="percent"
data={data}
color="#11b981"
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={BinaryChartTooltip}
pct
/>
)
}

View File

@ -10,8 +10,6 @@ import { getOutcomeProbability } from 'common/calculate'
import { DAY_MS } from 'common/util/time'
import {
TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatPct,
@ -21,64 +19,65 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
const CATEGORY_COLORS = [
'#00b8dd',
'#eecafe',
'#874c62',
'#6457ca',
'#f773ba',
'#9c6bbc',
'#a87744',
'#af8a04',
'#bff9aa',
'#f3d89d',
'#c9a0f5',
'#ff00e5',
'#9dc6f7',
'#824475',
'#d973cc',
'#bc6808',
'#056e70',
'#677932',
'#00b287',
'#c8ab6c',
'#a2fb7a',
'#f8db68',
'#14675a',
'#8288f4',
'#fe1ca0',
'#ad6aff',
'#786306',
'#9bfbaf',
'#b00cf7',
'#2f7ec5',
'#4b998b',
'#42fa0e',
'#5b80a1',
'#962d9d',
'#3385ff',
'#48c5ab',
'#b2c873',
'#4cf9a4',
'#00ffff',
'#3cca73',
'#99ae17',
'#7af5cf',
'#52af45',
'#fbb80f',
'#29971b',
'#187c9a',
'#00d539',
'#bbfa1a',
'#61f55c',
'#cabc03',
'#ff9000',
'#779100',
'#bcfd6f',
'#70a560',
export const CATEGORY_COLORS = [
'#7eb0d5',
'#fd7f6f',
'#b2e061',
'#bd7ebe',
'#ffb55a',
'#ffee65',
'#beb9db',
'#fdcce5',
'#8bd3c7',
'#bddfb7',
'#e2e3f3',
'#fafafa',
'#9fcdeb',
'#d3d3d3',
'#b1a296',
'#e1bdb6',
'#f2dbc0',
'#fae5d3',
'#c5e0ec',
'#e0f0ff',
'#ffddcd',
'#fbd5e2',
'#f2e7e5',
'#ffe7ba',
'#eed9c4',
'#ea9999',
'#f9cb9c',
'#ffe599',
'#b6d7a8',
'#a2c4c9',
'#9fc5e8',
'#b4a7d6',
'#d5a6bd',
'#e06666',
'#f6b26b',
'#ffd966',
'#93c47d',
'#76a5af',
'#6fa8dc',
'#8e7cc3',
'#c27ba0',
'#cc0000',
'#e69138',
'#f1c232',
'#6aa84f',
'#45818e',
'#3d85c6',
'#674ea7',
'#a64d79',
'#990000',
'#b45f06',
'#bf9000',
]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = (
contract: FreeResponseContract | MultipleChoiceContract,
topN: number
@ -142,6 +141,15 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
)
}
export function useChartAnswers(
contract: FreeResponseContract | MultipleChoiceContract
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
}
export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
@ -151,10 +159,7 @@ export const ChoiceContractChart = (props: {
}) => {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const answers = useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
const answers = useChartAnswers(contract)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo(
() => [
@ -178,9 +183,9 @@ export const ChoiceContractChart = (props: {
const ChoiceTooltip = useMemo(
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const { data, x, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const d = xScale.invert(x)
const legendItems = sortBy(
data.y.map((p, i) => ({
color: CATEGORY_COLORS[i],
@ -211,14 +216,15 @@ export const ChoiceContractChart = (props: {
<MultiValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
yKind="percent"
data={data}
colors={CATEGORY_COLORS}
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip}
pct
/>
)
}

View File

@ -6,9 +6,13 @@ import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
import { TooltipProps, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract
const step = (max - min) / bucketCount
@ -22,11 +26,11 @@ const getNumericChartData = (contract: NumericContract) => {
const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
const { data, x, xScale } = props
const amount = xScale.invert(x)
return (
<>
<span className="text-semibold">{formatLargeNumber(x)}</span>
<span className="text-semibold">{formatLargeNumber(amount)}</span>
<span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</>
)
@ -48,6 +52,7 @@ export const NumericContractChart = (props: {
<DistributionChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
data={data}

View File

@ -11,8 +11,6 @@ import { PseudoNumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import {
TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
@ -21,6 +19,10 @@ import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
// mqp: note that we have an idiosyncratic version of 'log scale'
// contracts. the values are stored "linearly" and can include zero.
// as a result, we have to do some weird-looking stuff in this code
@ -43,9 +45,9 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>>
) => {
const { data, mouseX, xScale } = props
const { data, x, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
@ -95,6 +97,7 @@ export const PseudoNumericContractChart = (props: {
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
data={data}

View File

@ -7,6 +7,8 @@ import {
CurveFactory,
SeriesPoint,
curveLinear,
curveStepBefore,
curveStepAfter,
stack,
stackOrderReverse,
} from 'd3-shape'
@ -14,34 +16,62 @@ import { range } from 'lodash'
import {
ContinuousScale,
Margin,
SVGChart,
AreaPath,
AreaWithTopStroke,
Point,
SliceMarker,
TooltipParams,
TooltipComponent,
computeColorStops,
formatPct,
} from './helpers'
import { useEvent } from 'web/hooks/use-event'
import { formatMoney } from 'common/util/format'
import { nanoid } from 'nanoid'
export type MultiPoint<T = unknown> = Point<Date, number[], T>
export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T>
export type ValueKind = 'm$' | 'percent' | 'amount'
type SliceExtent = { y0: number; y1: number }
const interpolateY = (
curve: CurveFactory,
x: number,
x0: number,
x1: number,
y0: number,
y1: number
) => {
if (curve === curveLinear) {
const p = (x - x0) / (x1 - x0)
return y0 * (1 - p) + y1 * p
} else if (curve === curveStepAfter) {
return y0
} else if (curve === curveStepBefore) {
return y1
}
}
const getTickValues = (min: number, max: number, n: number) => {
const step = (max - min) / (n - 1)
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
}
const betAtPointSelector = <X, Y, P extends Point<X, Y>>(
const dataAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1]
const result = item ? { ...item, x: posX } : undefined
return result
const i = bisect.left(data, x)
const prev = data[i - 1] as P | undefined
const next = data[i] as P | undefined
return { prev, next, x: posX }
}
}
@ -50,14 +80,16 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
w: number
h: number
color: string
margin: Margin
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P>
}) => {
const { color, data, yScale, w, h, curve, Tooltip } = props
const { data, w, h, color, margin, yScale, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale
@ -72,7 +104,18 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis }
}, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
@ -89,10 +132,13 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
<AreaWithTopStroke
@ -112,15 +158,17 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
w: number
h: number
colors: readonly string[]
margin: Margin
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { colors, data, yScale, w, h, curve, Tooltip, pct } = props
const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
@ -131,29 +179,47 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: axisLeft<number>(yScale)
const yAxis =
yKind === 'percent'
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
}, [w, h, yKind, xScale, yScale])
const series = useMemo(() => {
const d3Stack = stack<P, number>()
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
.value(({ y }, o) => y[o])
.value(({ y }, k) => y[k])
.order(stackOrderReverse)
return d3Stack(data)
}, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
@ -166,10 +232,13 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
{series.map((s, i) => (
@ -191,66 +260,137 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
data: P[]
w: number
h: number
color: string
color: string | ((p: P) => string)
margin: Margin
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { color, data, yScale, w, h, curve, Tooltip, pct } = props
const { data, w, h, color, margin, yScale, yKind, Tooltip } = props
const curve = props.curve ?? curveLinear
const [mouse, setMouse] = useState<TooltipParams<P> & SliceExtent>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py0 = yScale(0)
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: axisLeft<number>(yScale)
const yAxis =
yKind === 'percent'
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
}, [w, h, yKind, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
const x0 = p.prev ? xScale(p.prev.x) : xScale.range()[0]
const x1 = p.next ? xScale(p.next.x) : xScale.range()[1]
const y0 = p.prev ? yScale(p.prev.y) : yScale.range()[0]
const y1 = p.next ? yScale(p.next.y) : yScale.range()[1]
const markerY = interpolateY(curve, mouseX, x0, x1, y0, y1)
if (p.prev && markerY) {
setMouse({
x: mouseX,
y: markerY,
y0: py0,
y1: markerY,
data: p.prev,
})
} else {
setMouse(undefined)
}
})
const onMouseLeave = useEvent(() => setMouse(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
const newViewXScale = xScale
.copy()
.domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
setViewXScale(() => newViewXScale)
const dataInView = data.filter((p) => {
const x = newViewXScale(p.x)
return x >= 0 && x <= w
})
const yMin = Math.min(...dataInView.map((p) => p.y))
const yMax = Math.max(...dataInView.map((p) => p.y))
// Prevents very small selections from being too zoomed in
if (yMax - yMin > 0.05) {
// adds a little padding to the top and bottom of the selection
yScale.domain([yMin - (yMax - yMin) * 0.1, yMax + (yMax - yMin) * 0.1])
}
} else {
setViewXScale(undefined)
yScale.domain([0, 1])
}
})
const gradientId = useMemo(() => nanoid(), [])
const stops = useMemo(
() =>
typeof color !== 'string' ? computeColorStops(data, color, px) : null,
[color, data, px]
)
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={
mouse ? { x: mouse.x, y: mouse.y, data: mouse.data } : undefined
}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
{stops && (
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id={gradientId}>
{stops.map((s, i) => (
<stop key={i} offset={`${s.x / w}`} stopColor={s.color} />
))}
</linearGradient>
</defs>
)}
<AreaWithTopStroke
color={color}
color={typeof color === 'string' ? color : `url(#${gradientId})`}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
{mouse && (
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
)}
</SVGChart>
)
}

View File

@ -1,12 +1,4 @@
import {
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush'
@ -17,6 +9,7 @@ import clsx from 'clsx'
import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
@ -27,11 +20,12 @@ export interface ContinuousScale<T> extends AxisScale<T> {
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
export const MARGIN_X = MARGIN.right + MARGIN.left
export const MARGIN_Y = MARGIN.top + MARGIN.bottom
const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px`
const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})`
export type Margin = {
top: number
right: number
bottom: number
left: number
}
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
const { h, axis } = props
@ -55,8 +49,6 @@ export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
useEffect(() => {
if (axisRef.current != null) {
select(axisRef.current)
.transition()
.duration(250)
.call(axis)
.call((g) =>
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
@ -100,14 +92,14 @@ const AreaPathInternal = <P,>(
export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
export const AreaWithTopStroke = <P,>(props: {
color: string
data: P[]
color: string
px: number | ((p: P) => number)
py0: number | ((p: P) => number)
py1: number | ((p: P) => number)
curve: CurveFactory
}) => {
const { color, data, px, py0, py1, curve } = props
const { data, color, px, py0, py1, curve } = props
return (
<g>
<AreaPath
@ -124,23 +116,60 @@ export const AreaWithTopStroke = <P,>(props: {
)
}
export const SliceMarker = (props: {
color: string
x: number
y0: number
y1: number
}) => {
const { color, x, y0, y1 } = props
return (
<g>
<line stroke="white" strokeWidth={1} x1={x} x2={x} y1={y0} y2={y1} />
<circle
stroke="white"
strokeWidth={1}
fill={color}
cx={x}
cy={y1}
r={5}
/>
</g>
)
}
export const SVGChart = <X, TT>(props: {
children: ReactNode
w: number
h: number
margin: Margin
xAxis: Axis<X>
yAxis: Axis<number>
ttParams: TooltipParams<TT> | undefined
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
onMouseOver?: (mouseX: number, mouseY: number) => void
onMouseLeave?: () => void
Tooltip?: TooltipComponent<X, TT>
}) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const {
children,
w,
h,
margin,
xAxis,
yAxis,
ttParams,
onSelect,
onMouseOver,
onMouseLeave,
Tooltip,
} = props
const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X
const innerH = h - MARGIN_Y
const innerW = w - (margin.left + margin.right)
const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), [])
const isMobile = useIsMobile()
const justSelected = useRef(false)
useEffect(() => {
@ -155,7 +184,7 @@ export const SVGChart = <X, TT>(props: {
if (!justSelected.current) {
justSelected.current = true
onSelect(ev)
setMouse(undefined)
onMouseLeave?.()
if (overlayRef.current) {
select(overlayRef.current).call(brush.clear)
}
@ -171,43 +200,49 @@ export const SVGChart = <X, TT>(props: {
.select('.selection')
.attr('shape-rendering', 'null')
}
}, [innerW, innerH, onSelect])
}, [innerW, innerH, onSelect, onMouseLeave])
const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev)
const data = onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouse(undefined)
}
onMouseOver(x, y)
}
}
const onTouchMove = (ev: React.TouchEvent) => {
if (onMouseOver) {
const touch = ev.touches[0]
const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left
const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top
onMouseOver(x, y)
}
}
const onPointerLeave = () => {
setMouse(undefined)
onMouseLeave?.()
}
return (
<div className="relative overflow-hidden">
{mouse && Tooltip && (
{ttParams && Tooltip && (
<TooltipContainer
setElem={tooltipMeasure.setElem}
margin={margin}
pos={getTooltipPosition(
mouse.x,
mouse.y,
ttParams.x,
ttParams.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
tooltipMeasure.width ?? 140,
tooltipMeasure.height ?? 35,
isMobile ?? false
)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
x={ttParams.x}
y={ttParams.y}
data={ttParams.data}
/>
</TooltipContainer>
)}
@ -215,22 +250,34 @@ export const SVGChart = <X, TT>(props: {
<clipPath id={clipPathId}>
<rect x={0} y={0} width={innerW} height={innerH} />
</clipPath>
<g transform={MARGIN_XFORM}>
<g transform={`translate(${margin.left}, ${margin.top})`}>
<XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g>
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
{!isMobile ? (
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
) : (
<rect
x="0"
y="0"
width={innerW}
height={innerH}
fill="transparent"
onTouchMove={onTouchMove}
onTouchEnd={onPointerLeave}
/>
)}
</g>
</svg>
</div>
@ -244,41 +291,45 @@ export const getTooltipPosition = (
mouseY: number,
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
tooltipWidth: number,
tooltipHeight: number,
isMobile: boolean
) => {
let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
let bottom = !isMobile
? containerHeight - mouseY + 12
: containerHeight - tooltipHeight + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return { left, bottom }
}
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
export type TooltipParams<T> = { x: number; y: number; data: T }
export type TooltipProps<X, T> = TooltipParams<T> & {
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void
pos: TooltipPosition
margin: Margin
className?: string
children: React.ReactNode
}) => {
const { setElem, pos, className, children } = props
const { setElem, pos, margin, className, children } = props
return (
<div
ref={setElem}
@ -286,13 +337,43 @@ export const TooltipContainer = (props: {
className,
'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'
)}
style={{ margin: MARGIN_STYLE, ...pos }}
style={{
margin: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
...pos,
}}
>
{children}
</div>
)
}
export const computeColorStops = <P,>(
data: P[],
pc: (p: P) => string,
px: (p: P) => number
) => {
const segments: { x: number; color: string }[] = []
let currOffset = 0
let currColor = pc(data[0])
for (const p of data) {
const c = pc(p)
if (c !== currColor) {
segments.push({ x: currOffset, color: currColor })
currOffset = px(p)
currColor = c
}
}
segments.push({ x: currOffset, color: currColor })
const stops: { x: number; color: string }[] = []
stops.push({ x: segments[0].x, color: segments[0].color })
for (const s of segments.slice(1)) {
stops.push({ x: s.x, color: stops[stops.length - 1].color })
stops.push({ x: s.x, color: s.color })
}
return stops
}
export const getDateRange = (contract: Contract) => {
const { createdTime, closeTime, resolutionTime } = contract
const isClosed = !!closeTime && Date.now() > closeTime

View File

@ -6,9 +6,13 @@ import dayjs from 'dayjs'
import { formatPercent } from 'common/util/format'
import { Row } from '../layout/row'
import { HistoryPoint, SingleValueHistoryChart } from './generic-charts'
import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers'
import { TooltipProps } from './helpers'
import { SizedContainer } from 'web/components/sized-container'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getPoints = (startDate: number, dailyValues: number[]) => {
const startDateDayJs = dayjs(startDate)
return dailyValues.map((y, i) => ({
@ -18,8 +22,8 @@ const getPoints = (startDate: number, dailyValues: number[]) => {
}
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
const { data, x, xScale } = props
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
@ -29,8 +33,8 @@ const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
}
const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
const { data, x, xScale } = props
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
@ -63,12 +67,13 @@ export function DailyChart(props: {
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
yKind={pct ? 'percent' : 'amount'}
data={data}
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
color="#11b981"
pct={pct}
/>
)}
</SizedContainer>

View File

@ -138,16 +138,6 @@ export function CommentInputTextArea(props: {
<LoadingIndicator spinnerClassName="border-gray-500" />
)}
</TextEditor>
<Row>
{!user && (
<button
className="btn btn-outline btn-sm mt-2 normal-case"
onClick={submitComment}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -16,16 +16,18 @@ export function ConfirmationButton(props: {
}
cancelBtn?: {
label?: string
className?: string
color?: ColorType
}
submitBtn?: {
label?: string
className?: string
color?: ColorType
isSubmitting?: boolean
}
children: ReactNode
onSubmit?: () => void
onOpenChanged?: (isOpen: boolean) => void
onSubmitWithSuccess?: () => Promise<boolean>
disabled?: boolean
}) {
const {
openModalBtn,
@ -35,6 +37,7 @@ export function ConfirmationButton(props: {
children,
onOpenChanged,
onSubmitWithSuccess,
disabled,
} = props
const [open, setOpen] = useState(false)
@ -50,14 +53,14 @@ export function ConfirmationButton(props: {
<Col className="gap-4 rounded-md bg-white px-8 py-6">
{children}
<Row className="gap-4">
<div
className={clsx('btn', cancelBtn?.className)}
<Button
color={cancelBtn?.color ?? 'gray-white'}
onClick={() => updateOpen(false)}
>
{cancelBtn?.label ?? 'Cancel'}
</div>
<div
className={clsx('btn', submitBtn?.className)}
</Button>
<Button
color={submitBtn?.color ?? 'blue'}
onClick={
onSubmitWithSuccess
? () =>
@ -66,15 +69,22 @@ export function ConfirmationButton(props: {
)
: onSubmit
}
loading={submitBtn?.isSubmitting}
>
{submitBtn?.label ?? 'Submit'}
</div>
</Button>
</Row>
</Col>
</Modal>
<Button
className={clsx(openModalBtn.className)}
onClick={() => updateOpen(true)}
className={openModalBtn.className}
onClick={() => {
if (disabled) {
return
}
updateOpen(true)
}}
disabled={openModalBtn.disabled}
color={openModalBtn.color}
size={openModalBtn.size}
@ -90,18 +100,11 @@ export function ResolveConfirmationButton(props: {
onResolve: () => void
isSubmitting: boolean
openModalButtonClass?: string
submitButtonClass?: string
color?: ColorType
disabled?: boolean
}) {
const {
onResolve,
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
const { onResolve, isSubmitting, openModalButtonClass, color, disabled } =
props
return (
<ConfirmationButton
openModalBtn={{
@ -116,7 +119,8 @@ export function ResolveConfirmationButton(props: {
}}
submitBtn={{
label: 'Resolve',
className: clsx('border-none', submitButtonClass),
color: color,
isSubmitting,
}}
onSubmit={onResolve}
>

View File

@ -41,12 +41,15 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button'
import { Modal } from './layout/modal'
import { Title } from './title'
import { Input } from './input'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Most popular', value: 'most-popular' },
{ label: 'Liquidity', value: 'liquidity' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Closing soon', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
@ -436,16 +439,16 @@ function ContractSearchControls(props: {
return (
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2">
<input
<Input
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query: query })}
placeholder={'Search'}
className="input input-bordered w-full"
placeholder="Search"
className="w-full"
autoFocus={autoFocus}
/>
{!isMobile && (
{!isMobile && !query && (
<SearchFilters
filter={filter}
selectFilter={selectFilter}
@ -456,7 +459,7 @@ function ContractSearchControls(props: {
includeProbSorts={includeProbSorts}
/>
)}
{isMobile && (
{isMobile && !query && (
<>
<MobileSearchBar
children={

View File

@ -1,12 +1,16 @@
import clsx from 'clsx'
import { useState } from 'react'
import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
import { Tooltip } from 'web/components/tooltip'
import { CommentBountyDialog } from './comment-bounty-dialog'
export function BountiedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3 py-0.5 text-sm font-medium text-white">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span>
)
@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
}) {
const { contract, showAmount } = props
const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return (
<Tooltip
text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
const [open, setOpen] = useState(false)
if (!openCommentBounties && !showAmount) return <></>
const modal = (
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
)
}
export const CommentBountiesTooltipText = (
creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
const bountiesClosed =
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
if (!openCommentBounties) {
if (bountiesClosed) return <></>
return (
<>
{modal}
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
</>
)
}
const tooltip = `${contract.creatorName} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney(
openCommentBounties
)} currently available.`
return (
<Tooltip text={tooltip} placement="bottom">
{modal}
<SmallBadge
text={`${formatMoney(openCommentBounties)} bounty`}
onClick={bountiesClosed ? undefined : () => setOpen(true)}
/>
</Tooltip>
)
}
function SmallBadge(props: { text: string; onClick?: () => void }) {
const { text, onClick } = props
return (
<button
onClick={onClick}
className={clsx(
'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white',
!onClick && 'cursor-default'
)}
>
<CurrencyDollarIcon className={'h4 w-4'} />
{text}
</button>
)
}

Some files were not shown because too many files have changed in this diff Show More