Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
2e01e2d76b
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal file
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal 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 }}
|
|
@ -1,4 +1,4 @@
|
||||||
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
|
import { getCpmmLiquidity } from './calculate-cpmm'
|
||||||
import { CPMMContract } from './contract'
|
import { CPMMContract } from './contract'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
|
|
||||||
|
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
|
||||||
contract: CPMMContract,
|
contract: CPMMContract,
|
||||||
newLiquidityProvisionId: string
|
newLiquidityProvisionId: string
|
||||||
) => {
|
) => {
|
||||||
const { pool, p, totalLiquidity } = contract
|
const { pool, p, totalLiquidity, subsidyPool } = contract
|
||||||
|
|
||||||
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
|
const liquidity = getCpmmLiquidity(pool, p)
|
||||||
|
|
||||||
const liquidity =
|
|
||||||
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
|
|
||||||
|
|
||||||
const newLiquidityProvision: LiquidityProvision = {
|
const newLiquidityProvision: LiquidityProvision = {
|
||||||
id: newLiquidityProvisionId,
|
id: newLiquidityProvisionId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
pool: newPool,
|
pool,
|
||||||
p: newP,
|
p,
|
||||||
liquidity,
|
liquidity,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTotalLiquidity = (totalLiquidity ?? 0) + amount
|
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
123
common/badge.ts
Normal 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
|
||||||
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
import { sum, groupBy, mapValues, sumBy } from 'lodash'
|
import { groupBy, mapValues, sumBy } from 'lodash'
|
||||||
import { LimitBet } from './bet'
|
import { LimitBet } from './bet'
|
||||||
|
|
||||||
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
|
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import { computeFills } from './new-bet'
|
import { computeFills } from './new-bet'
|
||||||
import { binarySearch } from './util/algos'
|
import { binarySearch } from './util/algos'
|
||||||
import { addObjects } from './util/object'
|
|
||||||
|
|
||||||
export type CpmmState = {
|
export type CpmmState = {
|
||||||
pool: { [outcome: string]: number }
|
pool: { [outcome: string]: number }
|
||||||
|
@ -147,7 +146,8 @@ function calculateAmountToBuyShares(
|
||||||
state: CpmmState,
|
state: CpmmState,
|
||||||
shares: number,
|
shares: number,
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) {
|
) {
|
||||||
// Search for amount between bounds (0, shares).
|
// Search for amount between bounds (0, shares).
|
||||||
// Min share price is M$0, and max is M$1 each.
|
// Min share price is M$0, and max is M$1 each.
|
||||||
|
@ -157,7 +157,8 @@ function calculateAmountToBuyShares(
|
||||||
amount,
|
amount,
|
||||||
state,
|
state,
|
||||||
undefined,
|
undefined,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalShares = sumBy(takers, (taker) => taker.shares)
|
const totalShares = sumBy(takers, (taker) => taker.shares)
|
||||||
|
@ -169,7 +170,8 @@ export function calculateCpmmSale(
|
||||||
state: CpmmState,
|
state: CpmmState,
|
||||||
shares: number,
|
shares: number,
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) {
|
) {
|
||||||
if (Math.round(shares) < 0) {
|
if (Math.round(shares) < 0) {
|
||||||
throw new Error('Cannot sell non-positive shares')
|
throw new Error('Cannot sell non-positive shares')
|
||||||
|
@ -180,15 +182,17 @@ export function calculateCpmmSale(
|
||||||
state,
|
state,
|
||||||
shares,
|
shares,
|
||||||
oppositeOutcome,
|
oppositeOutcome,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
|
|
||||||
const { cpmmState, makers, takers, totalFees } = computeFills(
|
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
|
||||||
oppositeOutcome,
|
oppositeOutcome,
|
||||||
buyAmount,
|
buyAmount,
|
||||||
state,
|
state,
|
||||||
undefined,
|
undefined,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
|
|
||||||
// Transform buys of opposite outcome into sells.
|
// Transform buys of opposite outcome into sells.
|
||||||
|
@ -211,6 +215,7 @@ export function calculateCpmmSale(
|
||||||
fees: totalFees,
|
fees: totalFees,
|
||||||
makers,
|
makers,
|
||||||
takers: saleTakers,
|
takers: saleTakers,
|
||||||
|
ordersToCancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale(
|
||||||
state: CpmmState,
|
state: CpmmState,
|
||||||
shares: number,
|
shares: number,
|
||||||
outcome: 'YES' | 'NO',
|
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)
|
return getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,48 +266,22 @@ export function addCpmmLiquidity(
|
||||||
return { newPool, liquidity, newP }
|
return { newPool, liquidity, newP }
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
|
export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
|
||||||
const oldLiquidity = getCpmmLiquidity(l.pool, p)
|
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 })
|
return mapValues(
|
||||||
const newLiquidity = getCpmmLiquidity(newPool, p)
|
userAmounts,
|
||||||
|
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
|
||||||
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 totalUserWeights
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserLiquidityShares(
|
export function getUserLiquidityShares(
|
||||||
userId: string,
|
userId: string,
|
||||||
state: CpmmState,
|
state: CpmmState,
|
||||||
liquidities: LiquidityProvision[],
|
liquidities: LiquidityProvision[]
|
||||||
excludeAntes: boolean
|
|
||||||
) {
|
) {
|
||||||
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
|
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||||
const userWeight = weights[userId] ?? 0
|
const userWeight = weights[userId] ?? 0
|
||||||
|
|
||||||
return mapValues(state.pool, (shares) => userWeight * shares)
|
return mapValues(state.pool, (shares) => userWeight * shares)
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
import { last, sortBy, sum, sumBy } from 'lodash'
|
import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash'
|
||||||
import { calculatePayout } from './calculate'
|
import { calculatePayout, getContractBetMetrics } from './calculate'
|
||||||
import { Bet } from './bet'
|
import { Bet, LimitBet } from './bet'
|
||||||
import { Contract } from './contract'
|
import {
|
||||||
|
Contract,
|
||||||
|
CPMMBinaryContract,
|
||||||
|
CPMMContract,
|
||||||
|
DPMContract,
|
||||||
|
} from './contract'
|
||||||
import { PortfolioMetrics, User } from './user'
|
import { PortfolioMetrics, User } from './user'
|
||||||
import { DAY_MS } from './util/time'
|
import { DAY_MS } from './util/time'
|
||||||
|
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
|
||||||
|
import { getCpmmProbability } from './calculate-cpmm'
|
||||||
|
import { removeUndefinedProps } from './util/object'
|
||||||
|
|
||||||
const computeInvestmentValue = (
|
const computeInvestmentValue = (
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
|
@ -33,13 +41,81 @@ export const computeInvestmentValueCustomProb = (
|
||||||
|
|
||||||
const betP = outcome === 'YES' ? p : 1 - p
|
const betP = outcome === 'YES' ? p : 1 - p
|
||||||
|
|
||||||
const payout = betP * shares
|
const value = betP * shares
|
||||||
const value = payout - (bet.loanAmount ?? 0)
|
|
||||||
if (isNaN(value)) return 0
|
if (isNaN(value)) return 0
|
||||||
return value
|
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 computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||||
const periodFilteredContracts = userContracts.filter(
|
const periodFilteredContracts = userContracts.filter(
|
||||||
(contract) => contract.createdTime >= startTime
|
(contract) => contract.createdTime >= startTime
|
||||||
|
@ -123,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateProfitForPeriod = (
|
const calculateProfitForPeriod = (
|
||||||
startTime: number,
|
startingPortfolio: PortfolioMetrics | undefined,
|
||||||
descendingPortfolio: PortfolioMetrics[],
|
|
||||||
currentProfit: number
|
currentProfit: number
|
||||||
) => {
|
) => {
|
||||||
const startingPortfolio = descendingPortfolio.find(
|
|
||||||
(p) => p.timestamp < startTime
|
|
||||||
)
|
|
||||||
|
|
||||||
if (startingPortfolio === undefined) {
|
if (startingPortfolio === undefined) {
|
||||||
return currentProfit
|
return currentProfit
|
||||||
}
|
}
|
||||||
|
@ -145,33 +216,90 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculateNewProfit = (
|
export const calculateNewProfit = (
|
||||||
portfolioHistory: PortfolioMetrics[],
|
portfolioHistory: Record<
|
||||||
|
'current' | 'day' | 'week' | 'month',
|
||||||
|
PortfolioMetrics | undefined
|
||||||
|
>,
|
||||||
newPortfolio: PortfolioMetrics
|
newPortfolio: PortfolioMetrics
|
||||||
) => {
|
) => {
|
||||||
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
||||||
const descendingPortfolio = sortBy(
|
|
||||||
portfolioHistory,
|
|
||||||
(p) => p.timestamp
|
|
||||||
).reverse()
|
|
||||||
|
|
||||||
const newProfit = {
|
const newProfit = {
|
||||||
daily: calculateProfitForPeriod(
|
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
|
||||||
Date.now() - 1 * DAY_MS,
|
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
|
||||||
descendingPortfolio,
|
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
weekly: calculateProfitForPeriod(
|
|
||||||
Date.now() - 7 * DAY_MS,
|
|
||||||
descendingPortfolio,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
monthly: calculateProfitForPeriod(
|
|
||||||
Date.now() - 30 * DAY_MS,
|
|
||||||
descendingPortfolio,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
allTime: allTimeProfit,
|
allTime: allTimeProfit,
|
||||||
}
|
}
|
||||||
|
|
||||||
return newProfit
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -78,7 +78,8 @@ export function calculateShares(
|
||||||
export function calculateSaleAmount(
|
export function calculateSaleAmount(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bet: Bet,
|
bet: Bet,
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) {
|
) {
|
||||||
return contract.mechanism === 'cpmm-1' &&
|
return contract.mechanism === 'cpmm-1' &&
|
||||||
(contract.outcomeType === 'BINARY' ||
|
(contract.outcomeType === 'BINARY' ||
|
||||||
|
@ -87,7 +88,8 @@ export function calculateSaleAmount(
|
||||||
contract,
|
contract,
|
||||||
Math.abs(bet.shares),
|
Math.abs(bet.shares),
|
||||||
bet.outcome as 'YES' | 'NO',
|
bet.outcome as 'YES' | 'NO',
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
).saleValue
|
).saleValue
|
||||||
: calculateDpmSaleAmount(contract, bet)
|
: calculateDpmSaleAmount(contract, bet)
|
||||||
}
|
}
|
||||||
|
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
outcome: string,
|
outcome: string,
|
||||||
shares: number,
|
shares: number,
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) {
|
) {
|
||||||
return contract.mechanism === 'cpmm-1'
|
return contract.mechanism === 'cpmm-1'
|
||||||
? getCpmmProbabilityAfterSale(
|
? getCpmmProbabilityAfterSale(
|
||||||
contract,
|
contract,
|
||||||
shares,
|
shares,
|
||||||
outcome as 'YES' | 'NO',
|
outcome as 'YES' | 'NO',
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
|
: 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[]) {
|
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
const { resolution } = contract
|
const { resolution } = contract
|
||||||
const isCpmm = contract.mechanism === 'cpmm-1'
|
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 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 invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
|
||||||
const hasShares = Object.values(totalShares).some(
|
const hasShares = Object.values(totalShares).some(
|
||||||
|
@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invested,
|
invested,
|
||||||
|
loan,
|
||||||
payout,
|
payout,
|
||||||
netPayout,
|
|
||||||
profit,
|
profit,
|
||||||
profitPercent,
|
profitPercent,
|
||||||
totalShares,
|
totalShares,
|
||||||
|
@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
export function getContractBetNullMetrics() {
|
export function getContractBetNullMetrics() {
|
||||||
return {
|
return {
|
||||||
invested: 0,
|
invested: 0,
|
||||||
|
loan: 0,
|
||||||
payout: 0,
|
payout: 0,
|
||||||
netPayout: 0,
|
|
||||||
profit: 0,
|
profit: 0,
|
||||||
profitPercent: 0,
|
profitPercent: 0,
|
||||||
totalShares: {} as { [outcome: string]: number },
|
totalShares: {} as { [outcome: string]: number },
|
||||||
|
|
|
@ -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.`,
|
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) => {
|
].map((charity) => {
|
||||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
|
||||||
const { closeTime, groupLinks } = contract
|
const { closeTime, groupLinks } = contract
|
||||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(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 (
|
return (
|
||||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||||
|
|
|
@ -49,6 +49,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
volume: number
|
volume: number
|
||||||
volume24Hours: number
|
volume24Hours: number
|
||||||
volume7Days: number
|
volume7Days: number
|
||||||
|
elasticity: number
|
||||||
|
|
||||||
collectedFees: Fees
|
collectedFees: Fees
|
||||||
|
|
||||||
|
@ -62,7 +63,9 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
featuredOnHomeRank?: number
|
featuredOnHomeRank?: number
|
||||||
likedByUserIds?: string[]
|
likedByUserIds?: string[]
|
||||||
likedByUserCount?: number
|
likedByUserCount?: number
|
||||||
|
flaggedByUsernames?: string[]
|
||||||
openCommentBounties?: number
|
openCommentBounties?: number
|
||||||
|
unlistedById?: string
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
@ -88,7 +91,8 @@ export type CPMM = {
|
||||||
mechanism: 'cpmm-1'
|
mechanism: 'cpmm-1'
|
||||||
pool: { [outcome: string]: number }
|
pool: { [outcome: string]: number }
|
||||||
p: number // probability constant in y^p * n^(1-p) = k
|
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
|
prob: number
|
||||||
probChanges: {
|
probChanges: {
|
||||||
day: number
|
day: number
|
||||||
|
|
|
@ -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 UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
|
||||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
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 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 FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||||
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
|
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
|
||||||
|
|
||||||
|
export const UNIQUE_BETTOR_LIQUIDITY = 20
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
export const FLAT_TRADE_FEE = 0.1 // M$0.1
|
||||||
|
|
||||||
export const PLATFORM_FEE = 0
|
export const PLATFORM_FEE = 0
|
||||||
export const CREATOR_FEE = 0
|
export const CREATOR_FEE = 0
|
||||||
export const LIQUIDITY_FEE = 0
|
export const LIQUIDITY_FEE = 0
|
||||||
|
|
|
@ -39,3 +39,4 @@ export type GroupLink = {
|
||||||
createdTime: number
|
createdTime: number
|
||||||
userId?: string
|
userId?: string
|
||||||
}
|
}
|
||||||
|
export type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
|
|
@ -17,8 +17,7 @@ import {
|
||||||
import {
|
import {
|
||||||
CPMMBinaryContract,
|
CPMMBinaryContract,
|
||||||
DPMBinaryContract,
|
DPMBinaryContract,
|
||||||
FreeResponseContract,
|
DPMContract,
|
||||||
MultipleChoiceContract,
|
|
||||||
NumericContract,
|
NumericContract,
|
||||||
PseudoNumericContract,
|
PseudoNumericContract,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
|
@ -144,7 +143,8 @@ export const computeFills = (
|
||||||
betAmount: number,
|
betAmount: number,
|
||||||
state: CpmmState,
|
state: CpmmState,
|
||||||
limitProb: number | undefined,
|
limitProb: number | undefined,
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) => {
|
) => {
|
||||||
if (isNaN(betAmount)) {
|
if (isNaN(betAmount)) {
|
||||||
throw new Error('Invalid bet amount: ${betAmount}')
|
throw new Error('Invalid bet amount: ${betAmount}')
|
||||||
|
@ -166,10 +166,12 @@ export const computeFills = (
|
||||||
shares: number
|
shares: number
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}[] = []
|
}[] = []
|
||||||
|
const ordersToCancel: LimitBet[] = []
|
||||||
|
|
||||||
let amount = betAmount
|
let amount = betAmount
|
||||||
let cpmmState = { pool: state.pool, p: state.p }
|
let cpmmState = { pool: state.pool, p: state.p }
|
||||||
let totalFees = noFees
|
let totalFees = noFees
|
||||||
|
const currentBalanceByUserId = { ...balanceByUserId }
|
||||||
|
|
||||||
let i = 0
|
let i = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
|
@ -186,9 +188,20 @@ export const computeFills = (
|
||||||
takers.push(taker)
|
takers.push(taker)
|
||||||
} else {
|
} else {
|
||||||
// Matched against bet.
|
// 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)
|
takers.push(taker)
|
||||||
makers.push(maker)
|
makers.push(maker)
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
amount -= taker.amount
|
amount -= taker.amount
|
||||||
|
@ -196,7 +209,7 @@ export const computeFills = (
|
||||||
if (floatingEqual(amount, 0)) break
|
if (floatingEqual(amount, 0)) break
|
||||||
}
|
}
|
||||||
|
|
||||||
return { takers, makers, totalFees, cpmmState }
|
return { takers, makers, totalFees, cpmmState, ordersToCancel }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getBinaryCpmmBetInfo = (
|
export const getBinaryCpmmBetInfo = (
|
||||||
|
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
|
||||||
betAmount: number,
|
betAmount: number,
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||||
limitProb: number | undefined,
|
limitProb: number | undefined,
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const { pool, p } = contract
|
const { pool, p } = contract
|
||||||
const { takers, makers, cpmmState, totalFees } = computeFills(
|
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
|
||||||
outcome,
|
outcome,
|
||||||
betAmount,
|
betAmount,
|
||||||
{ pool, p },
|
{ pool, p },
|
||||||
limitProb,
|
limitProb,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
const probBefore = getCpmmProbability(contract.pool, contract.p)
|
const probBefore = getCpmmProbability(contract.pool, contract.p)
|
||||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||||
|
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
|
||||||
newP: cpmmState.p,
|
newP: cpmmState.p,
|
||||||
newTotalLiquidity,
|
newTotalLiquidity,
|
||||||
makers,
|
makers,
|
||||||
|
ordersToCancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
|
||||||
betAmount: number,
|
betAmount: number,
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||||
limitProb: number,
|
limitProb: number,
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const { newBet } = getBinaryCpmmBetInfo(
|
const { newBet } = getBinaryCpmmBetInfo(
|
||||||
outcome,
|
outcome,
|
||||||
betAmount ?? 0,
|
betAmount ?? 0,
|
||||||
contract,
|
contract,
|
||||||
limitProb,
|
limitProb,
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
const remainingMatched =
|
const remainingMatched =
|
||||||
((newBet.orderAmount ?? 0) - newBet.amount) /
|
((newBet.orderAmount ?? 0) - newBet.amount) /
|
||||||
|
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
|
||||||
export const getNewMultiBetInfo = (
|
export const getNewMultiBetInfo = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: DPMContract
|
||||||
) => {
|
) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
visibility,
|
visibility,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { parseTags, richTextToString } from './util/parse'
|
|
||||||
import { removeUndefinedProps } from './util/object'
|
import { removeUndefinedProps } from './util/object'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
@ -38,15 +37,6 @@ export function getNewContract(
|
||||||
answers: string[],
|
answers: string[],
|
||||||
visibility: visibility
|
visibility: visibility
|
||||||
) {
|
) {
|
||||||
const tags = parseTags(
|
|
||||||
[
|
|
||||||
question,
|
|
||||||
richTextToString(description),
|
|
||||||
...extraTags.map((tag) => `#${tag}`),
|
|
||||||
].join(' ')
|
|
||||||
)
|
|
||||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
|
||||||
|
|
||||||
const propsByOutcomeType =
|
const propsByOutcomeType =
|
||||||
outcomeType === 'BINARY'
|
outcomeType === 'BINARY'
|
||||||
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
||||||
|
@ -70,9 +60,10 @@ export function getNewContract(
|
||||||
|
|
||||||
question: question.trim(),
|
question: question.trim(),
|
||||||
description,
|
description,
|
||||||
tags,
|
tags: [],
|
||||||
lowercaseTags,
|
lowercaseTags: [],
|
||||||
visibility,
|
visibility,
|
||||||
|
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
closeTime,
|
closeTime,
|
||||||
|
@ -80,6 +71,7 @@ export function getNewContract(
|
||||||
volume: 0,
|
volume: 0,
|
||||||
volume24Hours: 0,
|
volume24Hours: 0,
|
||||||
volume7Days: 0,
|
volume7Days: 0,
|
||||||
|
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
|
||||||
|
|
||||||
collectedFees: {
|
collectedFees: {
|
||||||
creatorFee: 0,
|
creatorFee: 0,
|
||||||
|
@ -120,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
||||||
mechanism: 'cpmm-1',
|
mechanism: 'cpmm-1',
|
||||||
outcomeType: 'BINARY',
|
outcomeType: 'BINARY',
|
||||||
totalLiquidity: ante,
|
totalLiquidity: ante,
|
||||||
|
subsidyPool: 0,
|
||||||
initialProbability: p,
|
initialProbability: p,
|
||||||
p,
|
p,
|
||||||
pool: pool,
|
pool: pool,
|
||||||
|
|
|
@ -4,7 +4,7 @@ export type Notification = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
reasonText?: string
|
reasonText?: string
|
||||||
reason?: notification_reason_types
|
reason?: notification_reason_types | notification_preference
|
||||||
createdTime: number
|
createdTime: number
|
||||||
viewTime?: number
|
viewTime?: number
|
||||||
isSeen: boolean
|
isSeen: boolean
|
||||||
|
@ -46,6 +46,7 @@ export type notification_source_types =
|
||||||
| 'loan'
|
| 'loan'
|
||||||
| 'like'
|
| 'like'
|
||||||
| 'tip_and_like'
|
| 'tip_and_like'
|
||||||
|
| 'badge'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -96,6 +97,7 @@ type notification_descriptions = {
|
||||||
[key in notification_preference]: {
|
[key in notification_preference]: {
|
||||||
simple: string
|
simple: string
|
||||||
detailed: string
|
detailed: string
|
||||||
|
necessary?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
|
@ -208,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
detailed: 'Bonuses for unique predictors on your markets',
|
detailed: 'Bonuses for unique predictors on your markets',
|
||||||
},
|
},
|
||||||
your_contract_closed: {
|
your_contract_closed: {
|
||||||
simple: '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',
|
detailed: 'Your market has closed and you need to resolve it (necessary)',
|
||||||
|
necessary: true,
|
||||||
},
|
},
|
||||||
all_comments_on_watched_markets: {
|
all_comments_on_watched_markets: {
|
||||||
simple: 'All new comments',
|
simple: 'All new comments',
|
||||||
|
@ -235,6 +238,15 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
simple: `Only on markets you're invested in`,
|
simple: `Only on markets you're invested in`,
|
||||||
detailed: `Answers on markets that you're watching and that 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 = {
|
export type BettingStreakData = {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
import { getProbability } from './calculate'
|
import { getProbability } from './calculate'
|
||||||
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
|
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
|
||||||
|
@ -56,10 +55,10 @@ export const getLiquidityPoolPayouts = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
liquidities: LiquidityProvision[]
|
liquidities: LiquidityProvision[]
|
||||||
) => {
|
) => {
|
||||||
const { pool } = contract
|
const { pool, subsidyPool } = contract
|
||||||
const finalPool = pool[outcome]
|
const finalPool = pool[outcome] + subsidyPool
|
||||||
|
|
||||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
|
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||||
|
|
||||||
return Object.entries(weights).map(([providerId, weight]) => ({
|
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||||
userId: providerId,
|
userId: providerId,
|
||||||
|
@ -95,10 +94,10 @@ export const getLiquidityPoolProbPayouts = (
|
||||||
p: number,
|
p: number,
|
||||||
liquidities: LiquidityProvision[]
|
liquidities: LiquidityProvision[]
|
||||||
) => {
|
) => {
|
||||||
const { pool } = contract
|
const { pool, subsidyPool } = contract
|
||||||
const finalPool = p * pool.YES + (1 - p) * pool.NO
|
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]) => ({
|
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||||
userId: providerId,
|
userId: providerId,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { JSONContent } from '@tiptap/core'
|
||||||
export type Post = {
|
export type Post = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
subtitle: string
|
||||||
content: JSONContent
|
content: JSONContent
|
||||||
creatorId: string // User id
|
creatorId: string // User id
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
@ -17,3 +18,4 @@ export type DateDoc = Post & {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_POST_TITLE_LENGTH = 480
|
export const MAX_POST_TITLE_LENGTH = 480
|
||||||
|
export const MAX_POST_SUBTITLE_LENGTH = 480
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { groupBy, sumBy, mapValues } from 'lodash'
|
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
|
||||||
|
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
import { getContractBetMetrics } from './calculate'
|
import { getContractBetMetrics, resolvedPayout } from './calculate'
|
||||||
import { Contract } from './contract'
|
import { Contract } from './contract'
|
||||||
|
import { ContractComment } from './comment'
|
||||||
|
|
||||||
export function scoreCreators(contracts: Contract[]) {
|
export function scoreCreators(contracts: Contract[]) {
|
||||||
const creatorScore = mapValues(
|
const creatorScore = mapValues(
|
||||||
|
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||||
const betsByUser = groupBy(bets, bet => bet.userId)
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
|
return mapValues(
|
||||||
|
betsByUser,
|
||||||
|
(bets) => getContractBetMetrics(contract, bets).profit
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addUserScores(
|
export function addUserScores(
|
||||||
|
@ -43,3 +47,47 @@ export function addUserScores(
|
||||||
dest[userId] += score
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
contract: CPMMContract,
|
contract: CPMMContract,
|
||||||
unfilledBets: LimitBet[],
|
unfilledBets: LimitBet[],
|
||||||
|
balanceByUserId: { [userId: string]: number },
|
||||||
loanPaid: number
|
loanPaid: number
|
||||||
) => {
|
) => {
|
||||||
const { pool, p } = contract
|
const { pool, p } = contract
|
||||||
|
|
||||||
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
|
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
|
||||||
contract,
|
contract,
|
||||||
shares,
|
shares,
|
||||||
outcome,
|
outcome,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId,
|
||||||
)
|
)
|
||||||
|
|
||||||
const probBefore = getCpmmProbability(pool, p)
|
const probBefore = getCpmmProbability(pool, p)
|
||||||
|
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
|
||||||
fees,
|
fees,
|
||||||
makers,
|
makers,
|
||||||
takers,
|
takers,
|
||||||
|
ordersToCancel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,9 @@ export type notification_preferences = {
|
||||||
profit_loss_updates: notification_destination_types[]
|
profit_loss_updates: notification_destination_types[]
|
||||||
onboarding_flow: notification_destination_types[]
|
onboarding_flow: notification_destination_types[]
|
||||||
thank_you_for_purchases: 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 = (
|
export const getDefaultNotificationPreferences = (
|
||||||
|
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
|
||||||
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
||||||
return filterDefined([browser, email]) as notification_destination_types[]
|
return filterDefined([browser, email]) as notification_destination_types[]
|
||||||
}
|
}
|
||||||
return {
|
const defaults: notification_preferences = {
|
||||||
// Watched Markets
|
// Watched Markets
|
||||||
all_comments_on_watched_markets: constructPref(true, false),
|
all_comments_on_watched_markets: constructPref(true, false),
|
||||||
all_answers_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),
|
loan_income: constructPref(true, false),
|
||||||
betting_streaks: constructPref(true, false),
|
betting_streaks: constructPref(true, false),
|
||||||
referral_bonuses: constructPref(true, true),
|
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),
|
tipped_comments_on_watched_markets: constructPref(true, true),
|
||||||
tips_on_your_markets: constructPref(true, true),
|
tips_on_your_markets: constructPref(true, true),
|
||||||
limit_order_fills: constructPref(true, false),
|
limit_order_fills: constructPref(true, false),
|
||||||
|
@ -121,7 +124,11 @@ export const getDefaultNotificationPreferences = (
|
||||||
probability_updates_on_watched_markets: constructPref(true, false),
|
probability_updates_on_watched_markets: constructPref(true, false),
|
||||||
thank_you_for_purchases: constructPref(false, false),
|
thank_you_for_purchases: constructPref(false, false),
|
||||||
onboarding_flow: 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
|
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
||||||
|
@ -172,6 +179,8 @@ export const getNotificationDestinationsForUser = (
|
||||||
reason: notification_reason_types | notification_preference
|
reason: notification_reason_types | notification_preference
|
||||||
) => {
|
) => {
|
||||||
const notificationSettings = privateUser.notificationPreferences
|
const notificationSettings = privateUser.notificationPreferences
|
||||||
|
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||||
|
try {
|
||||||
let destinations
|
let destinations
|
||||||
let subscriptionType: notification_preference | undefined
|
let subscriptionType: notification_preference | undefined
|
||||||
if (Object.keys(notificationSettings).includes(reason)) {
|
if (Object.keys(notificationSettings).includes(reason)) {
|
||||||
|
@ -184,11 +193,30 @@ export const getNotificationDestinationsForUser = (
|
||||||
? notificationSettings[subscriptionType]
|
? notificationSettings[subscriptionType]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
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 {
|
return {
|
||||||
sendToEmail: destinations.includes('email'),
|
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||||
sendToBrowser: destinations.includes('browser'),
|
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${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: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { notification_preferences } from './user-notification-preferences'
|
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 = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -11,7 +12,6 @@ export type User = {
|
||||||
|
|
||||||
// For their user page
|
// For their user page
|
||||||
bio?: string
|
bio?: string
|
||||||
bannerUrl?: string
|
|
||||||
website?: string
|
website?: string
|
||||||
twitterHandle?: string
|
twitterHandle?: string
|
||||||
discordHandle?: string
|
discordHandle?: string
|
||||||
|
@ -33,6 +33,8 @@ export type User = {
|
||||||
allTime: number
|
allTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fractionResolvedCorrectly: number
|
||||||
|
|
||||||
nextLoanCached: number
|
nextLoanCached: number
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
|
@ -49,6 +51,18 @@ export type User = {
|
||||||
hasSeenContractFollowModal?: boolean
|
hasSeenContractFollowModal?: boolean
|
||||||
freeMarketsCreated?: number
|
freeMarketsCreated?: number
|
||||||
isBannedFromPosting?: boolean
|
isBannedFromPosting?: boolean
|
||||||
|
|
||||||
|
achievements: {
|
||||||
|
provenCorrect?: {
|
||||||
|
badges: ProvenCorrectBadge[]
|
||||||
|
}
|
||||||
|
marketCreator?: {
|
||||||
|
badges: MarketCreatorBadge[]
|
||||||
|
}
|
||||||
|
streaker?: {
|
||||||
|
badges: StreakerBadge[]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
|
@ -79,7 +93,8 @@ export type PortfolioMetrics = {
|
||||||
userId: string
|
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'
|
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||||
|
|
||||||
// TODO: remove. Hardcoding the strings would be better.
|
// TODO: remove. Hardcoding the strings would be better.
|
||||||
|
|
24
common/util/color.ts
Normal file
24
common/util/color.ts
Normal 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]
|
||||||
|
}
|
|
@ -13,7 +13,9 @@ export function formatMoney(amount: number) {
|
||||||
Math.round(amount) === 0
|
Math.round(amount) === 0
|
||||||
? 0
|
? 0
|
||||||
: // Handle 499.9999999999999 case
|
: // 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('$', '')
|
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
|
||||||
return `${numStr}${suffix[i] ?? ''}`
|
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) {
|
export function toCamelCase(words: string) {
|
||||||
const camelCase = words
|
const camelCase = words
|
||||||
.split(' ')
|
.split(' ')
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { MAX_TAG_LENGTH } from '../contract'
|
|
||||||
import { generateText, JSONContent } from '@tiptap/core'
|
import { generateText, JSONContent } from '@tiptap/core'
|
||||||
// Tiptap starter extensions
|
// Tiptap starter extensions
|
||||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
import { Blockquote } from '@tiptap/extension-blockquote'
|
||||||
|
@ -24,7 +23,7 @@ import { Mention } from '@tiptap/extension-mention'
|
||||||
import Iframe from './tiptap-iframe'
|
import Iframe from './tiptap-iframe'
|
||||||
import TiptapTweet from './tiptap-tweet-type'
|
import TiptapTweet from './tiptap-tweet-type'
|
||||||
import { find } from 'linkifyjs'
|
import { find } from 'linkifyjs'
|
||||||
import { uniq } from 'lodash'
|
import { cloneDeep, uniq } from 'lodash'
|
||||||
import { TiptapSpoiler } from './tiptap-spoiler'
|
import { TiptapSpoiler } from './tiptap-spoiler'
|
||||||
|
|
||||||
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
|
/** 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
|
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
|
// TODO: fuzzy matching
|
||||||
export const wordIn = (word: string, corpus: string) =>
|
export const wordIn = (word: string, corpus: string) =>
|
||||||
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
|
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
|
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||||
export const exhibitExts = [
|
const stringParseExts = [
|
||||||
Blockquote,
|
Blockquote,
|
||||||
Bold,
|
Bold,
|
||||||
BulletList,
|
BulletList,
|
||||||
|
@ -101,12 +72,35 @@ export const exhibitExts = [
|
||||||
|
|
||||||
Image,
|
Image,
|
||||||
Link,
|
Link,
|
||||||
Mention,
|
Mention, // user @mention
|
||||||
|
Mention.extend({ name: 'contract-mention' }), // market %mention
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
TiptapSpoiler,
|
TiptapSpoiler,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function richTextToString(text?: JSONContent) {
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ export const TiptapSpoiler = Mark.create<SpoilerOptions>({
|
||||||
exitable: true,
|
exitable: true,
|
||||||
content: 'inline*',
|
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() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
|
||||||
|
|
||||||
## Awarded bounties
|
## 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*
|
🎈 *Awarded on 2022-06-14*
|
||||||
|
|
||||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
||||||
|
|
|
@ -27,7 +27,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& 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
|
// User referral rules
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
@ -44,6 +44,10 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /{somePath=**}/contract-metrics/{contractId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
match /{somePath=**}/challenges/{challengeId}{
|
match /{somePath=**}/challenges/{challengeId}{
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
@ -102,7 +106,7 @@ service cloud.firestore {
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
|
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
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;
|
&& resource.data.creatorId == request.auth.uid;
|
||||||
allow update: if isAdmin();
|
allow update: if isAdmin();
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
|
|
3
functions/.env.dev
Normal file
3
functions/.env.dev
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# This sets which EnvConfig is deployed to Firebase Cloud Functions
|
||||||
|
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV=DEV
|
|
@ -5,7 +5,7 @@
|
||||||
"firestore": "dev-mantic-markets.appspot.com"
|
"firestore": "dev-mantic-markets.appspot.com"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"compile": "tsc -b",
|
||||||
"watch": "tsc -w",
|
"watch": "tsc -w",
|
||||||
"shell": "yarn build && firebase functions:shell",
|
"shell": "yarn build && firebase functions:shell",
|
||||||
|
|
|
@ -3,24 +3,18 @@ import { z } from 'zod'
|
||||||
|
|
||||||
import { Contract, CPMMContract } from '../../common/contract'
|
import { Contract, CPMMContract } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
|
||||||
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
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({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
amount: z.number().gt(0),
|
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)
|
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
|
// run as transaction to prevent race conditions
|
||||||
return await firestore.runTransaction(async (transaction) => {
|
return await firestore.runTransaction(async (transaction) => {
|
||||||
|
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||||
.collection(`contracts/${contractId}/liquidity`)
|
.collection(`contracts/${contractId}/liquidity`)
|
||||||
.doc()
|
.doc()
|
||||||
|
|
||||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
|
||||||
getNewLiquidityProvision(
|
getNewLiquidityProvision(
|
||||||
user.id,
|
user.id,
|
||||||
amount,
|
amount,
|
||||||
|
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||||
newLiquidityProvisionDoc.id
|
newLiquidityProvisionDoc.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (newP !== undefined && !isFinite(newP)) {
|
transaction.update(contractDoc, {
|
||||||
return {
|
subsidyPool: newSubsidyPool,
|
||||||
status: 'error',
|
|
||||||
message: 'Liquidity injection rejected due to overflow error.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.update(
|
|
||||||
contractDoc,
|
|
||||||
removeUndefinedProps({
|
|
||||||
pool: newPool,
|
|
||||||
p: newP,
|
|
||||||
totalLiquidity: newTotalLiquidity,
|
totalLiquidity: newTotalLiquidity,
|
||||||
})
|
} as Partial<CPMMContract>)
|
||||||
)
|
|
||||||
|
|
||||||
const newBalance = user.balance - amount
|
const newBalance = user.balance - amount
|
||||||
const newTotalDeposits = user.totalDeposits - amount
|
const newTotalDeposits = user.totalDeposits - amount
|
||||||
|
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||||
},
|
},
|
||||||
} as EndpointDefinition
|
} 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
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,13 @@ import {
|
||||||
Notification,
|
Notification,
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
} from '../../common/notification'
|
} 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 { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getValues } from './utils'
|
import { getPrivateUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
|
@ -30,27 +36,26 @@ import {
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||||
import { ContractFollow } from '../../common/follow'
|
import { ContractFollow } from '../../common/follow'
|
||||||
|
import { Badge } from 'common/badge'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type recipients_to_reason_texts = {
|
type recipients_to_reason_texts = {
|
||||||
[userId: string]: { reason: notification_reason_types }
|
[userId: string]: { reason: notification_reason_types }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotification = async (
|
export const createFollowOrMarketSubsidizedNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: 'contract' | 'liquidity' | 'follow',
|
sourceType: 'liquidity' | 'follow',
|
||||||
sourceUpdateType: 'closed' | 'created',
|
sourceUpdateType: 'created',
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
miscData?: {
|
miscData?: {
|
||||||
contract?: Contract
|
contract?: Contract
|
||||||
recipients?: string[]
|
recipients?: string[]
|
||||||
slug?: string
|
|
||||||
title?: string
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
const { contract: sourceContract, recipients } = miscData ?? {}
|
||||||
|
|
||||||
const shouldReceiveNotification = (
|
const shouldReceiveNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
|
@ -94,23 +99,15 @@ export const createNotification = async (
|
||||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||||
sourceContractTitle: sourceContract?.question,
|
sourceContractTitle: sourceContract?.question,
|
||||||
sourceContractSlug: sourceContract?.slug,
|
sourceContractSlug: sourceContract?.slug,
|
||||||
sourceSlug: slug ? slug : sourceContract?.slug,
|
sourceSlug: sourceContract?.slug,
|
||||||
sourceTitle: title ? title : sourceContract?.question,
|
sourceTitle: sourceContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sendToEmail) continue
|
if (!sendToEmail) continue
|
||||||
|
|
||||||
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
|
if (reason === 'subsidized_your_market') {
|
||||||
// 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') {
|
|
||||||
// TODO: send email to creator of market that was subsidized
|
// TODO: send email to creator of market that was subsidized
|
||||||
} else if (reason === 'on_new_follow') {
|
} else if (reason === 'on_new_follow') {
|
||||||
// TODO: send email to user who was followed
|
// TODO: send email to user who was followed
|
||||||
|
@ -127,20 +124,7 @@ export const createNotification = async (
|
||||||
reason: 'on_new_follow',
|
reason: 'on_new_follow',
|
||||||
}
|
}
|
||||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
} else if (
|
} else if (sourceType === 'liquidity' && sourceContract) {
|
||||||
sourceType === 'contract' &&
|
|
||||||
sourceUpdateType === 'closed' &&
|
|
||||||
sourceContract
|
|
||||||
) {
|
|
||||||
userToReasonTexts[sourceContract.creatorId] = {
|
|
||||||
reason: 'your_contract_closed',
|
|
||||||
}
|
|
||||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
|
||||||
} else if (
|
|
||||||
sourceType === 'liquidity' &&
|
|
||||||
sourceUpdateType === 'created' &&
|
|
||||||
sourceContract
|
|
||||||
) {
|
|
||||||
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
|
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
|
||||||
userToReasonTexts[sourceContract.creatorId] = {
|
userToReasonTexts[sourceContract.creatorId] = {
|
||||||
reason: 'subsidized_your_market',
|
reason: 'subsidized_your_market',
|
||||||
|
@ -1087,6 +1071,81 @@ export const createBountyNotification = async (
|
||||||
sourceTitle: contract.question,
|
sourceTitle: contract.question,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,11 @@ import * as admin from 'firebase-admin'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
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 { APIError, newEndpoint, validate } from './api'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
@ -36,6 +40,7 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
|
|
||||||
const postSchema = z.object({
|
const postSchema = z.object({
|
||||||
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
||||||
|
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
|
||||||
content: contentSchema,
|
content: contentSchema,
|
||||||
groupId: z.string().optional(),
|
groupId: z.string().optional(),
|
||||||
|
|
||||||
|
@ -48,10 +53,8 @@ const postSchema = z.object({
|
||||||
|
|
||||||
export const createpost = newEndpoint({}, async (req, auth) => {
|
export const createpost = newEndpoint({}, async (req, auth) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const { title, content, groupId, question, ...otherProps } = validate(
|
const { title, subtitle, content, groupId, question, ...otherProps } =
|
||||||
postSchema,
|
validate(postSchema, req.body)
|
||||||
req.body
|
|
||||||
)
|
|
||||||
|
|
||||||
const creator = await getUser(auth.uid)
|
const creator = await getUser(auth.uid)
|
||||||
if (!creator)
|
if (!creator)
|
||||||
|
@ -68,6 +71,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
||||||
if (question) {
|
if (question) {
|
||||||
const closeTime = Date.now() + DAY_MS * 30 * 3
|
const closeTime = Date.now() + DAY_MS * 30 * 3
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await createMarketHelper(
|
const result = await createMarketHelper(
|
||||||
{
|
{
|
||||||
question,
|
question,
|
||||||
|
@ -81,6 +85,9 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
||||||
auth
|
auth
|
||||||
)
|
)
|
||||||
contractSlug = result.slug
|
contractSlug = result.slug
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const post: Post = removeUndefinedProps({
|
const post: Post = removeUndefinedProps({
|
||||||
|
@ -89,6 +96,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
||||||
creatorId: creator.id,
|
creatorId: creator.id,
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
content: content,
|
content: content,
|
||||||
contractSlug,
|
contractSlug,
|
||||||
|
|
|
@ -69,6 +69,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
followerCountCached: 0,
|
followerCountCached: 0,
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
shouldShowWelcome: true,
|
shouldShowWelcome: true,
|
||||||
|
fractionResolvedCorrectly: 1,
|
||||||
|
achievements: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('users').doc(auth.uid).create(user)
|
await firestore.collection('users').doc(auth.uid).create(user)
|
||||||
|
|
69
functions/src/drizzle-liquidity.ts
Normal file
69
functions/src/drizzle-liquidity.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
|
@ -483,11 +483,7 @@
|
||||||
color: #999;
|
color: #999;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>!
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { contractUrl, getUser } from './utils'
|
import { contractUrl, getUser, log } from './utils'
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
import { notification_reason_types } from '../../common/notification'
|
import { notification_reason_types } from '../../common/notification'
|
||||||
import { Dictionary } from 'lodash'
|
import { Dictionary } from 'lodash'
|
||||||
|
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'onboarding_flow'
|
'onboarding_flow'
|
||||||
)
|
)
|
||||||
|
if (!sendToEmail) return
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
sendTime: string
|
sendTime: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'onboarding_flow'
|
'onboarding_flow'
|
||||||
)
|
)
|
||||||
|
if (!sendToEmail) return
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Create your own prediction market',
|
'Create your own prediction market',
|
||||||
|
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
|
|
||||||
'email'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'thank_you_for_purchases'
|
'thank_you_for_purchases'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!sendToEmail) return
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Thanks for your Manifold purchase',
|
'Thanks for your Manifold purchase',
|
||||||
|
@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async (
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
if (!privateUser.email) return
|
||||||
privateUser,
|
|
||||||
reason
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!privateUser.email || !sendToEmail) return
|
|
||||||
|
|
||||||
const { username, name, id: userId } = user
|
const { username, name, id: userId } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async (
|
||||||
|
|
||||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
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(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Your market has closed',
|
'Your market has closed',
|
||||||
|
@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async (
|
||||||
{
|
{
|
||||||
question,
|
question,
|
||||||
url,
|
url,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl: '',
|
||||||
userId,
|
userId,
|
||||||
name: firstName,
|
name: firstName,
|
||||||
volume: formatMoney(volume),
|
volume: formatMoney(volume),
|
||||||
|
@ -466,17 +448,13 @@ export const sendInterestingMarketsEmail = async (
|
||||||
contractsToSend: Contract[],
|
contractsToSend: Contract[],
|
||||||
deliveryTime?: string
|
deliveryTime?: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.trending_markets.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'trending_markets'
|
'trending_markets'
|
||||||
)
|
)
|
||||||
|
if (!sendToEmail) return
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -620,18 +598,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
||||||
investments: PerContractInvestmentsData[],
|
investments: PerContractInvestmentsData[],
|
||||||
overallPerformance: OverallPerformanceData
|
overallPerformance: OverallPerformanceData
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'profit_loss_updates'
|
'profit_loss_updates'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!sendToEmail) return
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const templateData: Record<string, string> = {
|
const templateData: Record<string, string> = {
|
||||||
|
@ -656,4 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
||||||
: 'portfolio-update',
|
: 'portfolio-update',
|
||||||
templateData
|
templateData
|
||||||
)
|
)
|
||||||
|
log('Sent portfolio update email to', privateUser.email)
|
||||||
}
|
}
|
||||||
|
|
42
functions/src/helpers/add-house-subsidy.ts
Normal file
42
functions/src/helpers/add-house-subsidy.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ export * from './on-create-user'
|
||||||
export * from './on-create-bet'
|
export * from './on-create-bet'
|
||||||
export * from './on-create-comment-on-contract'
|
export * from './on-create-comment-on-contract'
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
export * from './update-metrics'
|
export { scheduleUpdateMetrics } from './update-metrics'
|
||||||
export * from './update-stats'
|
export * from './update-stats'
|
||||||
export * from './update-loans'
|
export * from './update-loans'
|
||||||
export * from './backup-db'
|
export * from './backup-db'
|
||||||
|
@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags'
|
||||||
export * from './on-update-contract-follow'
|
export * from './on-update-contract-follow'
|
||||||
export * from './on-update-like'
|
export * from './on-update-like'
|
||||||
export * from './weekly-portfolio-emails'
|
export * from './weekly-portfolio-emails'
|
||||||
|
export * from './drizzle-liquidity'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -44,8 +45,6 @@ export * from './sell-bet'
|
||||||
export * from './sell-shares'
|
export * from './sell-shares'
|
||||||
export * from './claim-manalink'
|
export * from './claim-manalink'
|
||||||
export * from './create-market'
|
export * from './create-market'
|
||||||
export * from './add-liquidity'
|
|
||||||
export * from './withdraw-liquidity'
|
|
||||||
export * from './create-group'
|
export * from './create-group'
|
||||||
export * from './resolve-market'
|
export * from './resolve-market'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
|
@ -53,6 +52,7 @@ export * from './stripe'
|
||||||
export * from './mana-bonus-email'
|
export * from './mana-bonus-email'
|
||||||
export * from './close-market'
|
export * from './close-market'
|
||||||
export * from './update-comment-bounty'
|
export * from './update-comment-bounty'
|
||||||
|
export * from './add-subsidy'
|
||||||
|
|
||||||
import { health } from './health'
|
import { health } from './health'
|
||||||
import { transact } from './transact'
|
import { transact } from './transact'
|
||||||
|
@ -65,9 +65,7 @@ import { sellbet } from './sell-bet'
|
||||||
import { sellshares } from './sell-shares'
|
import { sellshares } from './sell-shares'
|
||||||
import { claimmanalink } from './claim-manalink'
|
import { claimmanalink } from './claim-manalink'
|
||||||
import { createmarket } from './create-market'
|
import { createmarket } from './create-market'
|
||||||
import { addliquidity } from './add-liquidity'
|
|
||||||
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
||||||
import { withdrawliquidity } from './withdraw-liquidity'
|
|
||||||
import { creategroup } from './create-group'
|
import { creategroup } from './create-group'
|
||||||
import { resolvemarket } from './resolve-market'
|
import { resolvemarket } from './resolve-market'
|
||||||
import { closemarket } from './close-market'
|
import { closemarket } from './close-market'
|
||||||
|
@ -77,6 +75,8 @@ import { getcurrentuser } from './get-current-user'
|
||||||
import { acceptchallenge } from './accept-challenge'
|
import { acceptchallenge } from './accept-challenge'
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||||
|
import { updatemetrics } from './update-metrics'
|
||||||
|
import { addsubsidy } from './add-subsidy'
|
||||||
|
|
||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
return onRequest(opts, handler as any)
|
return onRequest(opts, handler as any)
|
||||||
|
@ -92,10 +92,9 @@ const sellBetFunction = toCloudFunction(sellbet)
|
||||||
const sellSharesFunction = toCloudFunction(sellshares)
|
const sellSharesFunction = toCloudFunction(sellshares)
|
||||||
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
||||||
const createMarketFunction = toCloudFunction(createmarket)
|
const createMarketFunction = toCloudFunction(createmarket)
|
||||||
const addLiquidityFunction = toCloudFunction(addliquidity)
|
const addSubsidyFunction = toCloudFunction(addsubsidy)
|
||||||
const addCommentBounty = toCloudFunction(addcommentbounty)
|
const addCommentBounty = toCloudFunction(addcommentbounty)
|
||||||
const awardCommentBounty = toCloudFunction(awardcommentbounty)
|
const awardCommentBounty = toCloudFunction(awardcommentbounty)
|
||||||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
|
||||||
const createGroupFunction = toCloudFunction(creategroup)
|
const createGroupFunction = toCloudFunction(creategroup)
|
||||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||||
const closeMarketFunction = toCloudFunction(closemarket)
|
const closeMarketFunction = toCloudFunction(closemarket)
|
||||||
|
@ -106,6 +105,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||||
const createPostFunction = toCloudFunction(createpost)
|
const createPostFunction = toCloudFunction(createpost)
|
||||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||||
|
const updateMetricsFunction = toCloudFunction(updatemetrics)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
healthFunction as health,
|
healthFunction as health,
|
||||||
|
@ -119,8 +119,7 @@ export {
|
||||||
sellSharesFunction as sellshares,
|
sellSharesFunction as sellshares,
|
||||||
claimManalinkFunction as claimmanalink,
|
claimManalinkFunction as claimmanalink,
|
||||||
createMarketFunction as createmarket,
|
createMarketFunction as createmarket,
|
||||||
addLiquidityFunction as addliquidity,
|
addSubsidyFunction as addsubsidy,
|
||||||
withdrawLiquidityFunction as withdrawliquidity,
|
|
||||||
createGroupFunction as creategroup,
|
createGroupFunction as creategroup,
|
||||||
resolveMarketFunction as resolvemarket,
|
resolveMarketFunction as resolvemarket,
|
||||||
closeMarketFunction as closemarket,
|
closeMarketFunction as closemarket,
|
||||||
|
@ -133,4 +132,5 @@ export {
|
||||||
saveTwitchCredentials as savetwitchcredentials,
|
saveTwitchCredentials as savetwitchcredentials,
|
||||||
addCommentBounty as addcommentbounty,
|
addCommentBounty as addcommentbounty,
|
||||||
awardCommentBounty as awardcommentbounty,
|
awardCommentBounty as awardcommentbounty,
|
||||||
|
updateMetricsFunction as updatemetrics,
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getUserByUsername } from './utils'
|
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
|
export const marketCloseNotifications = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
.pubsub.schedule('every 1 hours')
|
.pubsub.schedule('every 1 hours')
|
||||||
|
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
async function sendMarketCloseEmails() {
|
export async function sendMarketCloseEmails() {
|
||||||
const contracts = await firestore.runTransaction(async (transaction) => {
|
const contracts = await firestore.runTransaction(async (transaction) => {
|
||||||
const snap = await transaction.get(
|
const snap = await transaction.get(
|
||||||
firestore.collection('contracts').where('isResolved', '!=', true)
|
firestore.collection('contracts').where('isResolved', '!=', true)
|
||||||
)
|
)
|
||||||
|
const contracts = snap.docs.map((doc) => doc.data() as Contract)
|
||||||
return snap.docs
|
const now = Date.now()
|
||||||
.map((doc) => {
|
const closeContracts = contracts.filter(
|
||||||
const contract = doc.data() as Contract
|
(contract) =>
|
||||||
|
contract.closeTime &&
|
||||||
if (
|
contract.closeTime < now &&
|
||||||
contract.resolution ||
|
shouldSendFirstOrFollowUpCloseNotification(contract)
|
||||||
(contract.closeEmailsSent ?? 0) >= 1 ||
|
|
||||||
contract.closeTime === undefined ||
|
|
||||||
(contract.closeTime ?? 0) > Date.now()
|
|
||||||
)
|
)
|
||||||
return undefined
|
|
||||||
|
|
||||||
transaction.update(doc.ref, {
|
await Promise.all(
|
||||||
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
|
closeContracts.map(async (contract) => {
|
||||||
|
await transaction.update(
|
||||||
|
firestore.collection('contracts').doc(contract.id),
|
||||||
|
{
|
||||||
|
closeEmailsSent: admin.firestore.FieldValue.increment(1),
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
)
|
||||||
return contract
|
return closeContracts
|
||||||
})
|
|
||||||
.filter((x) => !!x) as Contract[]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const contract of contracts) {
|
for (const contract of contracts) {
|
||||||
|
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
|
||||||
const privateUser = await getPrivateUser(user.id)
|
const privateUser = await getPrivateUser(user.id)
|
||||||
if (!privateUser) continue
|
if (!privateUser) continue
|
||||||
|
|
||||||
await createNotification(
|
await createMarketClosedNotification(
|
||||||
contract.id,
|
contract,
|
||||||
'contract',
|
|
||||||
'closed',
|
|
||||||
user,
|
user,
|
||||||
contract.id + '-closed-at-' + contract.closeTime,
|
privateUser,
|
||||||
contract.closeTime?.toString() ?? new Date().toString(),
|
contract.id + '-closed-at-' + contract.closeTime
|
||||||
{ contract }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
revalidateStaticProps,
|
revalidateStaticProps,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import {
|
import {
|
||||||
|
createBadgeAwardedNotification,
|
||||||
createBetFillNotification,
|
createBetFillNotification,
|
||||||
createBettingStreakBonusNotification,
|
createBettingStreakBonusNotification,
|
||||||
createUniqueBettorBonusNotification,
|
createUniqueBettorBonusNotification,
|
||||||
|
@ -24,6 +25,7 @@ import {
|
||||||
BETTING_STREAK_BONUS_MAX,
|
BETTING_STREAK_BONUS_MAX,
|
||||||
BETTING_STREAK_RESET_HOUR,
|
BETTING_STREAK_RESET_HOUR,
|
||||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
|
UNIQUE_BETTOR_LIQUIDITY,
|
||||||
} from '../../common/economy'
|
} from '../../common/economy'
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||||
|
import { addHouseSubsidy } from './helpers/add-house-subsidy'
|
||||||
|
import {
|
||||||
|
StreakerBadge,
|
||||||
|
streakerBadgeRarityThresholds,
|
||||||
|
} from '../../common/badge'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
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
|
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
|
||||||
// Otherwise, add 1 to their betting streak
|
// Otherwise, add 1 to their betting streak
|
||||||
await trans.update(userDoc, {
|
trans.update(userDoc, {
|
||||||
currentBettingStreak: newBettingStreak,
|
currentBettingStreak: newBettingStreak,
|
||||||
lastBetTime: bet.createdTime,
|
lastBetTime: bet.createdTime,
|
||||||
})
|
})
|
||||||
|
@ -143,7 +150,7 @@ const updateBettingStreak = async (
|
||||||
log('message:', result.message)
|
log('message:', result.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.txn)
|
if (result.txn) {
|
||||||
await createBettingStreakBonusNotification(
|
await createBettingStreakBonusNotification(
|
||||||
user,
|
user,
|
||||||
result.txn.id,
|
result.txn.id,
|
||||||
|
@ -153,6 +160,8 @@ const updateBettingStreak = async (
|
||||||
newBettingStreak,
|
newBettingStreak,
|
||||||
eventId
|
eventId
|
||||||
)
|
)
|
||||||
|
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
|
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||||
|
|
||||||
await trans.update(contractDoc, {
|
trans.update(contractDoc, {
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
})
|
})
|
||||||
|
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
return { newUniqueBettorIds }
|
return { newUniqueBettorIds }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!newUniqueBettorIds) return
|
if (!newUniqueBettorIds) return
|
||||||
|
|
||||||
|
if (oldContract.mechanism === 'cpmm-1') {
|
||||||
|
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
|
||||||
|
}
|
||||||
|
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
contractId: oldContract.id,
|
contractId: oldContract.id,
|
||||||
uniqueNewBettorId: bettor.id,
|
uniqueNewBettorId: bettor.id,
|
||||||
|
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||||
|
|
||||||
const fromUser = fromSnap.data() as User
|
const fromUser = fromSnap.data() as User
|
||||||
|
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
const bonusTxn: TxnData = {
|
const bonusTxn: TxnData = {
|
||||||
fromId: fromUser.id,
|
fromId: fromUser.id,
|
||||||
|
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
description: JSON.stringify(bonusTxnDetails),
|
description: JSON.stringify(bonusTxnDetails),
|
||||||
data: bonusTxnDetails,
|
data: bonusTxnDetails,
|
||||||
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||||
|
|
||||||
const { status, message, txn } = await runTxn(trans, bonusTxn)
|
const { status, message, txn } = await runTxn(trans, bonusTxn)
|
||||||
|
|
||||||
return { status, newUniqueBettorIds, message, txn }
|
return { status, newUniqueBettorIds, message, txn }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -296,3 +314,39 @@ const notifyFills = async (
|
||||||
const currentDateBettingStreakResetTime = () => {
|
const currentDateBettingStreakResetTime = () => {
|
||||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,20 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
|
|
||||||
import { getUser } from './utils'
|
import { getUser, getValues } from './utils'
|
||||||
import { createNewContractNotification } from './create-notification'
|
import {
|
||||||
|
createBadgeAwardedNotification,
|
||||||
|
createNewContractNotification,
|
||||||
|
} from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
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
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -28,4 +37,43 @@ export const onCreateContract = functions
|
||||||
richTextToString(desc),
|
richTextToString(desc),
|
||||||
mentioned
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser, log } from './utils'
|
import { getContract, getUser, log } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
import { FIXED_ANTE } from '../../common/economy'
|
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')
|
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||||
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||||
|
|
||||||
await createNotification(
|
await createFollowOrMarketSubsidizedNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'liquidity',
|
'liquidity',
|
||||||
'created',
|
'created',
|
||||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
export const onFollowUser = functions.firestore
|
export const onFollowUser = functions.firestore
|
||||||
|
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
|
||||||
followerCountCached: FieldValue.increment(1),
|
followerCountCached: FieldValue.increment(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
await createNotification(
|
await createFollowOrMarketSubsidizedNotification(
|
||||||
followingUser.id,
|
followingUser.id,
|
||||||
'follow',
|
'follow',
|
||||||
'created',
|
'created',
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getUser, getValues, log } from './utils'
|
import { getUser, getValues } from './utils'
|
||||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
import {
|
||||||
|
createBadgeAwardedNotification,
|
||||||
|
createCommentOrAnswerOrUpdatedContractNotification,
|
||||||
|
} from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { Txn } from '../../common/txn'
|
import { Bet } from '../../common/bet'
|
||||||
import { partition, sortBy } from 'lodash'
|
|
||||||
import { runTxn, TxnData } from './transact'
|
|
||||||
import * as admin from 'firebase-admin'
|
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
|
export const onUpdateContract = functions.firestore
|
||||||
.document('contracts/{contractId}')
|
.document('contracts/{contractId}')
|
||||||
|
@ -13,18 +21,14 @@ export const onUpdateContract = functions.firestore
|
||||||
const contract = change.after.data() as Contract
|
const contract = change.after.data() as Contract
|
||||||
const previousContract = change.before.data() as Contract
|
const previousContract = change.before.data() as Contract
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
const { openCommentBounties, closeTime, question } = contract
|
const { closeTime, question } = contract
|
||||||
|
|
||||||
if (
|
if (!previousContract.isResolved && contract.isResolved) {
|
||||||
!previousContract.isResolved &&
|
|
||||||
contract.isResolved &&
|
|
||||||
(openCommentBounties ?? 0) > 0
|
|
||||||
) {
|
|
||||||
await handleUnusedCommentBountyRefunds(contract)
|
|
||||||
// No need to notify users of resolution, that's handled in resolve-market
|
// No need to notify users of resolution, that's handled in resolve-market
|
||||||
return
|
return await handleResolvedContract(contract)
|
||||||
}
|
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
|
||||||
if (
|
await handleContractGroupUpdated(previousContract, contract)
|
||||||
|
} else if (
|
||||||
previousContract.closeTime !== closeTime ||
|
previousContract.closeTime !== closeTime ||
|
||||||
previousContract.question !== question
|
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(
|
async function handleUpdatedCloseTime(
|
||||||
previousContract: Contract,
|
previousContract: Contract,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
|
@ -57,62 +118,42 @@ async function handleUpdatedCloseTime(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnusedCommentBountyRefunds(contract: Contract) {
|
async function handleContractGroupUpdated(
|
||||||
const outstandingCommentBounties = await getValues<Txn>(
|
previousContract: Contract,
|
||||||
firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY')
|
contract: Contract
|
||||||
)
|
) {
|
||||||
|
const prevLength = previousContract.groupSlugs?.length ?? 0
|
||||||
const commentBountiesOnThisContract = sortBy(
|
const newLength = contract.groupSlugs?.length ?? 0
|
||||||
outstandingCommentBounties.filter(
|
if (prevLength < newLength) {
|
||||||
(bounty) => bounty.data?.contractId === contract.id
|
// Contract was added to a new group
|
||||||
),
|
const groupId = contract.groupLinks?.find(
|
||||||
(bounty) => bounty.createdTime
|
(link) =>
|
||||||
)
|
!previousContract.groupLinks
|
||||||
|
?.map((l) => l.groupId)
|
||||||
const [toBank, fromBank] = partition(
|
.includes(link.groupId)
|
||||||
commentBountiesOnThisContract,
|
)?.groupId
|
||||||
(bounty) => bounty.toType === 'BANK'
|
if (!groupId) throw new Error('Could not find new group id')
|
||||||
)
|
|
||||||
if (toBank.length <= fromBank.length) return
|
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
.update({ openCommentBounties: 0 })
|
.set({
|
||||||
|
|
||||||
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,
|
contractId: contract.id,
|
||||||
},
|
createdTime: Date.now(),
|
||||||
|
} as GroupContractDoc)
|
||||||
}
|
}
|
||||||
return await runTxn(trans, bonusTxn)
|
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')
|
||||||
|
|
||||||
if (result.status != 'success' || !result.txn) {
|
await firestore
|
||||||
log(
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
`Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`,
|
.doc(contract.id)
|
||||||
result.status
|
.delete()
|
||||||
)
|
|
||||||
log('message:', result.message)
|
|
||||||
} else {
|
|
||||||
log(
|
|
||||||
`Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`,
|
|
||||||
result.txn?.id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||||
import { createReferralNotification } from './create-notification'
|
import { createReferralNotification } from './create-notification'
|
||||||
import { ReferralTxn } from '../../common/txn'
|
import { ReferralTxn } from '../../common/txn'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { LimitBet } from '../../common/bet'
|
|
||||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { REFERRAL_AMOUNT } from '../../common/economy'
|
import { REFERRAL_AMOUNT } from '../../common/economy'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
|
||||||
if (prevUser.referredByUserId !== user.referredByUserId) {
|
if (prevUser.referredByUserId !== user.referredByUserId) {
|
||||||
await handleUserUpdatedReferral(user, eventId)
|
await handleUserUpdatedReferral(user, eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.balance <= 0) {
|
|
||||||
await cancelLimitOrders(user.id)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleUserUpdatedReferral(user: User, eventId: string) {
|
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 }))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
import { FLAT_TRADE_FEE } from '../../common/fees'
|
||||||
import {
|
import {
|
||||||
BetInfo,
|
BetInfo,
|
||||||
getBinaryCpmmBetInfo,
|
getBinaryCpmmBetInfo,
|
||||||
|
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
import { log } from './utils'
|
import { log } from './utils'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
newTotalLiquidity,
|
newTotalLiquidity,
|
||||||
newP,
|
newP,
|
||||||
makers,
|
makers,
|
||||||
|
ordersToCancel,
|
||||||
} = await (async (): Promise<
|
} = await (async (): Promise<
|
||||||
BetInfo & {
|
BetInfo & {
|
||||||
makers?: maker[]
|
makers?: maker[]
|
||||||
|
ordersToCancel?: LimitBet[]
|
||||||
}
|
}
|
||||||
> => {
|
> => {
|
||||||
if (
|
if (
|
||||||
|
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
limitProb = Math.round(limitProb * 100) / 100
|
limitProb = Math.round(limitProb * 100) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
const unfilledBetsSnap = await trans.get(
|
const { unfilledBets, balanceByUserId } =
|
||||||
getUnfilledBetsQuery(contractDoc)
|
await getUnfilledBetsAndUserBalances(trans, contractDoc)
|
||||||
)
|
|
||||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
|
||||||
|
|
||||||
return getBinaryCpmmBetInfo(
|
return getBinaryCpmmBetInfo(
|
||||||
outcome,
|
outcome,
|
||||||
amount,
|
amount,
|
||||||
contract,
|
contract,
|
||||||
limitProb,
|
limitProb,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
|
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
if (makers) {
|
if (makers) {
|
||||||
updateMakers(makers, betDoc.id, contractDoc, trans)
|
updateMakers(makers, betDoc.id, contractDoc, trans)
|
||||||
}
|
}
|
||||||
|
if (ordersToCancel) {
|
||||||
|
for (const bet of ordersToCancel) {
|
||||||
|
trans.update(contractDoc.collection('bets').doc(bet.id), {
|
||||||
|
isCancelled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (newBet.amount !== 0) {
|
const balanceChange =
|
||||||
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
|
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.')
|
log('Updated user balance.')
|
||||||
|
|
||||||
|
if (newBet.amount !== 0) {
|
||||||
trans.update(
|
trans.update(
|
||||||
contractDoc,
|
contractDoc,
|
||||||
removeUndefinedProps({
|
removeUndefinedProps({
|
||||||
|
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
|
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
|
||||||
return contractDoc
|
return contractDoc
|
||||||
.collection('bets')
|
.collection('bets')
|
||||||
.where('isFilled', '==', false)
|
.where('isFilled', '==', false)
|
||||||
.where('isCancelled', '==', false) as Query<LimitBet>
|
.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 = {
|
type maker = {
|
||||||
bet: LimitBet
|
bet: LimitBet
|
||||||
amount: number
|
amount: number
|
||||||
|
|
|
@ -9,7 +9,15 @@ import {
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { Bet } from '../../common/bet'
|
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 {
|
import {
|
||||||
getLoanPayouts,
|
getLoanPayouts,
|
||||||
getPayouts,
|
getPayouts,
|
||||||
|
@ -145,6 +153,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
resolutions,
|
resolutions,
|
||||||
collectedFees,
|
collectedFees,
|
||||||
}),
|
}),
|
||||||
|
subsidyPool: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
await contractDoc.update(updatedContract)
|
await contractDoc.update(updatedContract)
|
||||||
|
|
30
functions/src/scripts/add-new-notification-preference.ts
Normal file
30
functions/src/scripts/add-new-notification-preference.ts
Normal 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())
|
129
functions/src/scripts/backfill-badges.ts
Normal file
129
functions/src/scripts/backfill-badges.ts
Normal 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
|
||||||
|
}
|
|
@ -50,3 +50,5 @@ async function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
65
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal file
65
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal 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 {}
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||||
import { isEqual, zip } from 'lodash'
|
import { isEqual, zip } from 'lodash'
|
||||||
import { UpdateSpec } from '../utils'
|
|
||||||
|
|
||||||
export type DocumentValue = {
|
export type DocumentValue = {
|
||||||
doc: DocumentSnapshot
|
doc: DocumentSnapshot
|
||||||
|
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
|
||||||
return {
|
return {
|
||||||
doc: diff.dest.doc.ref,
|
doc: diff.dest.doc.ref,
|
||||||
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
|
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
|
||||||
} as UpdateSpec
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
||||||
|
|
8
functions/src/scripts/drizzle.ts
Normal file
8
functions/src/scripts/drizzle.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
import { drizzleLiquidity } from '../drizzle-liquidity'
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
drizzleLiquidity().then(() => process.exit())
|
||||||
|
}
|
|
@ -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())
|
|
||||||
}
|
|
|
@ -89,17 +89,20 @@ const getGroups = async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async function updateTotalContractsAndMembers() {
|
async function updateTotalContractsAndMembers() {
|
||||||
const groups = await getGroups()
|
const groups = await getGroups()
|
||||||
for (const group of groups) {
|
await Promise.all(
|
||||||
|
groups.map(async (group) => {
|
||||||
log('updating group total contracts and members', group.slug)
|
log('updating group total contracts and members', group.slug)
|
||||||
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||||
const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
const totalMembers = (await groupRef.collection('groupMembers').get())
|
||||||
|
.size
|
||||||
const totalContracts = (await groupRef.collection('groupContracts').get())
|
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||||
.size
|
.size
|
||||||
await groupRef.update({
|
await groupRef.update({
|
||||||
totalMembers,
|
totalMembers,
|
||||||
totalContracts,
|
totalContracts,
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async function removeUnusedMemberAndContractFields() {
|
async function removeUnusedMemberAndContractFields() {
|
||||||
|
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
initAdmin()
|
initAdmin()
|
||||||
// convertGroupFieldsToGroupDocuments()
|
// convertGroupFieldsToGroupDocuments()
|
||||||
// updateTotalContractsAndMembers()
|
updateTotalContractsAndMembers()
|
||||||
removeUnusedMemberAndContractFields()
|
// removeUnusedMemberAndContractFields()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
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 { log } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
import { removeUserFromContractFollowers } from './follow-market'
|
import { removeUserFromContractFollowers } from './follow-market'
|
||||||
|
|
||||||
|
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||||
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
|
const [
|
||||||
await Promise.all([
|
[contractSnap, userSnap],
|
||||||
|
userBetsSnap,
|
||||||
|
{ unfilledBets, balanceByUserId },
|
||||||
|
] = await Promise.all([
|
||||||
transaction.getAll(contractDoc, userDoc),
|
transaction.getAll(contractDoc, userDoc),
|
||||||
transaction.get(betsQ),
|
transaction.get(betsQ),
|
||||||
transaction.get(getUnfilledBetsQuery(contractDoc)),
|
getUnfilledBetsAndUserBalances(transaction, contractDoc),
|
||||||
])
|
])
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
|
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 contract = contractSnap.data() as Contract
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
|
@ -86,11 +88,13 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
let loanPaid = saleFrac * loanAmount
|
let loanPaid = saleFrac * loanAmount
|
||||||
if (!isFinite(loanPaid)) loanPaid = 0
|
if (!isFinite(loanPaid)) loanPaid = 0
|
||||||
|
|
||||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
const { newBet, newPool, newP, fees, makers, ordersToCancel } =
|
||||||
|
getCpmmSellBetInfo(
|
||||||
soldShares,
|
soldShares,
|
||||||
chosenOutcome,
|
chosenOutcome,
|
||||||
contract,
|
contract,
|
||||||
unfilledBets,
|
unfilledBets,
|
||||||
|
balanceByUserId,
|
||||||
loanPaid
|
loanPaid
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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 }
|
return { newBet, makers, maxShares, soldShares }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -19,8 +19,6 @@ import { sellbet } from './sell-bet'
|
||||||
import { sellshares } from './sell-shares'
|
import { sellshares } from './sell-shares'
|
||||||
import { claimmanalink } from './claim-manalink'
|
import { claimmanalink } from './claim-manalink'
|
||||||
import { createmarket } from './create-market'
|
import { createmarket } from './create-market'
|
||||||
import { addliquidity } from './add-liquidity'
|
|
||||||
import { withdrawliquidity } from './withdraw-liquidity'
|
|
||||||
import { creategroup } from './create-group'
|
import { creategroup } from './create-group'
|
||||||
import { resolvemarket } from './resolve-market'
|
import { resolvemarket } from './resolve-market'
|
||||||
import { unsubscribe } from './unsubscribe'
|
import { unsubscribe } from './unsubscribe'
|
||||||
|
@ -61,10 +59,8 @@ addJsonEndpointRoute('/sellbet', sellbet)
|
||||||
addJsonEndpointRoute('/sellshares', sellshares)
|
addJsonEndpointRoute('/sellshares', sellshares)
|
||||||
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
||||||
addJsonEndpointRoute('/createmarket', createmarket)
|
addJsonEndpointRoute('/createmarket', createmarket)
|
||||||
addJsonEndpointRoute('/addliquidity', addliquidity)
|
|
||||||
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
|
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
|
||||||
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
|
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
|
||||||
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
|
||||||
addJsonEndpointRoute('/creategroup', creategroup)
|
addJsonEndpointRoute('/creategroup', creategroup)
|
||||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { APIError, newEndpoint } from './api'
|
import { APIError, newEndpoint } from './api'
|
||||||
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
|
|
||||||
import { isProd } from './utils'
|
import { isProd } from './utils'
|
||||||
|
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
|
||||||
|
|
||||||
// Function for testing scheduled functions locally
|
// Function for testing scheduled functions locally
|
||||||
export const testscheduledfunction = newEndpoint(
|
export const testscheduledfunction = newEndpoint(
|
||||||
|
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
|
||||||
throw new APIError(400, 'This function is only available in dev mode')
|
throw new APIError(400, 'This function is only available in dev mode')
|
||||||
|
|
||||||
// Replace your function here
|
// Replace your function here
|
||||||
await sendPortfolioUpdateEmailsToAllUsers()
|
await sendMarketCloseEmails()
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
|
||||||
import { PrivateUser } from '../../common/user'
|
import { PrivateUser } from '../../common/user'
|
||||||
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
||||||
import { notification_preference } from '../../common/user-notification-preferences'
|
import { notification_preference } from '../../common/user-notification-preferences'
|
||||||
|
import { getFunctionUrl } from '../../common/api'
|
||||||
|
|
||||||
export const unsubscribe: EndpointDefinition = {
|
export const unsubscribe: EndpointDefinition = {
|
||||||
opts: { method: 'GET', minInstances: 1 },
|
opts: { method: 'GET', minInstances: 1 },
|
||||||
|
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
res.status(400).send('Invalid subscription type parameter.')
|
res.status(400).send('Invalid subscription type parameter.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const optOutAllType: notification_preference = 'opt_out_all'
|
||||||
|
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
|
||||||
|
|
||||||
const user = await getPrivateUser(id)
|
const user = await getPrivateUser(id)
|
||||||
|
|
||||||
|
@ -31,20 +34,181 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
const previousDestinations =
|
const previousDestinations =
|
||||||
user.notificationPreferences[notificationSubscriptionType]
|
user.notificationPreferences[notificationSubscriptionType]
|
||||||
|
|
||||||
|
let newDestinations = previousDestinations
|
||||||
|
if (wantsToOptOutAll) newDestinations.push('email')
|
||||||
|
else
|
||||||
|
newDestinations = previousDestinations.filter(
|
||||||
|
(destination) => destination !== 'email'
|
||||||
|
)
|
||||||
|
|
||||||
console.log(previousDestinations)
|
console.log(previousDestinations)
|
||||||
const { email } = user
|
const { email } = user
|
||||||
|
|
||||||
const update: Partial<PrivateUser> = {
|
const update: Partial<PrivateUser> = {
|
||||||
notificationPreferences: {
|
notificationPreferences: {
|
||||||
...user.notificationPreferences,
|
...user.notificationPreferences,
|
||||||
[notificationSubscriptionType]: previousDestinations.filter(
|
[notificationSubscriptionType]: newDestinations,
|
||||||
(destination) => destination !== 'email'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(id).update(update)
|
await firestore.collection('private-users').doc(id).update(update)
|
||||||
|
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||||
|
|
||||||
|
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>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;">
|
||||||
|
${email} has opted out of receiving unnecessary email notifications
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
res.send(
|
res.send(
|
||||||
`
|
`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
@ -52,7 +216,7 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
<title>Unsubscribe from Manifold Markets emails</title>
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
|
@ -197,6 +361,12 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
<span>Click
|
<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>
|
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
||||||
to manage the rest of your notification settings.
|
to manage the rest of your notification settings.
|
||||||
</span>
|
</span>
|
||||||
|
@ -222,6 +392,7 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
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 { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract, CPMM } from '../../common/contract'
|
import { Contract, CPMM } from '../../common/contract'
|
||||||
|
|
||||||
import { PortfolioMetrics, User } from '../../common/user'
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { getLoanUpdates } from '../../common/loans'
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
@ -14,18 +15,44 @@ import {
|
||||||
calculateNewPortfolioMetrics,
|
calculateNewPortfolioMetrics,
|
||||||
calculateNewProfit,
|
calculateNewProfit,
|
||||||
calculateProbChanges,
|
calculateProbChanges,
|
||||||
|
calculateMetricsByContract,
|
||||||
|
computeElasticity,
|
||||||
computeVolume,
|
computeVolume,
|
||||||
} from '../../common/calculate-metrics'
|
} from '../../common/calculate-metrics'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { batchedWaitAll } from '../../common/util/promise'
|
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()
|
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
|
const json = await response.json()
|
||||||
.runWith({ memory: '8GB', timeoutSeconds: 540 })
|
|
||||||
.pubsub.schedule('every 15 minutes')
|
if (response.ok) console.log(json)
|
||||||
.onRun(updateMetricsCore)
|
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() {
|
export async function updateMetricsCore() {
|
||||||
console.log('Loading users')
|
console.log('Loading users')
|
||||||
|
@ -35,11 +62,7 @@ export async function updateMetricsCore() {
|
||||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||||
|
|
||||||
console.log('Loading portfolio history')
|
console.log('Loading portfolio history')
|
||||||
const allPortfolioHistories = await getValues<PortfolioMetrics>(
|
const userPortfolioHistory = await loadPortfolioHistory(users)
|
||||||
firestore
|
|
||||||
.collectionGroup('portfolioHistory')
|
|
||||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Loading groups')
|
console.log('Loading groups')
|
||||||
const groups = await getValues<Group>(firestore.collection('groups'))
|
const groups = await getValues<Group>(firestore.collection('groups'))
|
||||||
|
@ -103,6 +126,7 @@ export async function updateMetricsCore() {
|
||||||
fields: {
|
fields: {
|
||||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||||
|
elasticity: computeElasticity(contractBets, contract),
|
||||||
...cpmmFields,
|
...cpmmFields,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -115,11 +139,10 @@ export async function updateMetricsCore() {
|
||||||
)
|
)
|
||||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
|
||||||
|
|
||||||
const userMetrics = users.map((user) => {
|
const userMetrics = users.map((user) => {
|
||||||
const currentBets = betsByUser[user.id] ?? []
|
const currentBets = betsByUser[user.id] ?? []
|
||||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
const portfolioHistory = userPortfolioHistory[user.id] ?? []
|
||||||
const userContracts = contractsByUser[user.id] ?? []
|
const userContracts = contractsByUser[user.id] ?? []
|
||||||
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
||||||
const newPortfolio = calculateNewPortfolioMetrics(
|
const newPortfolio = calculateNewPortfolioMetrics(
|
||||||
|
@ -127,21 +150,51 @@ export async function updateMetricsCore() {
|
||||||
contractsById,
|
contractsById,
|
||||||
currentBets
|
currentBets
|
||||||
)
|
)
|
||||||
const lastPortfolio = last(portfolioHistory)
|
const currPortfolio = portfolioHistory.current
|
||||||
const didPortfolioChange =
|
const didPortfolioChange =
|
||||||
lastPortfolio === undefined ||
|
currPortfolio === undefined ||
|
||||||
lastPortfolio.balance !== newPortfolio.balance ||
|
currPortfolio.balance !== newPortfolio.balance ||
|
||||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
currPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||||
|
|
||||||
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
newCreatorVolume,
|
newCreatorVolume,
|
||||||
newPortfolio,
|
newPortfolio,
|
||||||
newProfit,
|
newProfit,
|
||||||
didPortfolioChange,
|
didPortfolioChange,
|
||||||
|
newFractionResolvedCorrectly,
|
||||||
|
metricsByContract,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -157,61 +210,61 @@ export async function updateMetricsCore() {
|
||||||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||||
|
|
||||||
const userUpdates = userMetrics.map(
|
const userUpdates = userMetrics.map(
|
||||||
({
|
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
|
||||||
user,
|
|
||||||
newCreatorVolume,
|
|
||||||
newPortfolio,
|
|
||||||
newProfit,
|
|
||||||
didPortfolioChange,
|
|
||||||
}) => {
|
|
||||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||||
return {
|
return {
|
||||||
fieldUpdates: {
|
|
||||||
doc: firestore.collection('users').doc(user.id),
|
doc: firestore.collection('users').doc(user.id),
|
||||||
fields: {
|
fields: {
|
||||||
creatorVolumeCached: newCreatorVolume,
|
creatorVolumeCached: newCreatorVolume,
|
||||||
profitCached: newProfit,
|
profitCached: newProfit,
|
||||||
nextLoanCached,
|
nextLoanCached,
|
||||||
|
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await writeAsync(firestore, userUpdates)
|
||||||
|
|
||||||
subcollectionUpdates: {
|
const portfolioHistoryUpdates = filterDefined(
|
||||||
|
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
|
||||||
|
return didPortfolioChange
|
||||||
|
? {
|
||||||
doc: firestore
|
doc: firestore
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(user.id)
|
.doc(user.id)
|
||||||
.collection('portfolioHistory')
|
.collection('portfolioHistory')
|
||||||
.doc(),
|
.doc(),
|
||||||
fields: didPortfolioChange ? newPortfolio : {},
|
fields: newPortfolio,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
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,
|
await writeAsync(firestore, contractMetricsUpdates, 'set')
|
||||||
userUpdates.map((u) => u.fieldUpdates)
|
|
||||||
)
|
|
||||||
await writeAsync(
|
|
||||||
firestore,
|
|
||||||
userUpdates
|
|
||||||
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
|
|
||||||
.map((u) => u.subcollectionUpdates),
|
|
||||||
'set'
|
|
||||||
)
|
|
||||||
log(`Updated metrics for ${users.length} users.`)
|
log(`Updated metrics for ${users.length} users.`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupUpdates = groups.map((group, index) => {
|
const groupUpdates = groups.map((group, index) => {
|
||||||
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
||||||
const groupContracts = groupContractIds
|
const groupContracts = filterDefined(
|
||||||
.map((e) => contractsById[e.contractId])
|
groupContractIds.map((e) => contractsById[e.contractId])
|
||||||
.filter((e) => e !== undefined) as Contract[]
|
)
|
||||||
const bets = groupContracts.map((e) => {
|
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
|
||||||
if (e != null && e.id in betsByContract) {
|
|
||||||
return betsByContract[e.id] ?? []
|
|
||||||
} else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const creatorScores = scoreCreators(groupContracts)
|
const creatorScores = scoreCreators(groupContracts)
|
||||||
const traderScores = scoreTraders(groupContracts, bets)
|
const traderScores = scoreTraders(groupContracts, bets)
|
||||||
|
@ -243,3 +296,46 @@ const topUserScores = (scores: { [userId: string]: number }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupContractDoc = { contractId: string; createdTime: 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)
|
||||||
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ export const writeAsync = async (
|
||||||
const batch = db.batch()
|
const batch = db.batch()
|
||||||
for (const { doc, fields } of chunks[i]) {
|
for (const { doc, fields } of chunks[i]) {
|
||||||
if (operationType === 'update') {
|
if (operationType === 'update') {
|
||||||
batch.update(doc, fields)
|
batch.update(doc, fields as any)
|
||||||
} else {
|
} else {
|
||||||
batch.set(doc, fields)
|
batch.set(doc, fields)
|
||||||
}
|
}
|
||||||
|
@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
|
||||||
return users.docs.map((doc) => doc.data() as PrivateUser)
|
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) => {
|
export const getUserByUsername = async (username: string) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const snap = await firestore
|
const snap = await firestore
|
||||||
|
|
|
@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import {
|
import {
|
||||||
getAllPrivateUsers,
|
getAllPrivateUsers,
|
||||||
|
getGroup,
|
||||||
getPrivateUser,
|
getPrivateUser,
|
||||||
getUser,
|
getUser,
|
||||||
getValues,
|
getValues,
|
||||||
isProd,
|
isProd,
|
||||||
log,
|
log,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { sendInterestingMarketsEmail } from './emails'
|
|
||||||
import { createRNG, shuffle } from '../../common/util/random'
|
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 { filterDefined } from '../../common/util/array'
|
||||||
|
import { Follow } from '../../common/follow'
|
||||||
|
import { countBy, uniq, uniqBy } from 'lodash'
|
||||||
|
import { sendInterestingMarketsEmail } from './emails'
|
||||||
|
|
||||||
export const weeklyMarketsEmails = functions
|
export const weeklyMarketsEmails = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
||||||
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
|
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
|
||||||
.pubsub.schedule('* 19 * * 1')
|
.pubsub.schedule('* 19-20 * * 1')
|
||||||
.timeZone('Etc/UTC')
|
.timeZone('Etc/UTC')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await sendTrendingMarketsEmailsToAllUsers()
|
await sendTrendingMarketsEmailsToAllUsers()
|
||||||
|
@ -40,20 +43,30 @@ export async function getTrendingContracts() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTrendingMarketsEmailsToAllUsers() {
|
export async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
const numContractsToSend = 6
|
const numContractsToSend = 6
|
||||||
const privateUsers = isProd()
|
const privateUsers = isProd()
|
||||||
? await getAllPrivateUsers()
|
? await getAllPrivateUsers()
|
||||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
: filterDefined([
|
||||||
// get all users that haven't unsubscribed from weekly emails
|
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
|
||||||
|
])
|
||||||
const privateUsersToSendEmailsTo = privateUsers
|
const privateUsersToSendEmailsTo = privateUsers
|
||||||
.filter((user) => {
|
// Get all users that haven't unsubscribed from weekly emails
|
||||||
return (
|
.filter(
|
||||||
|
(user) =>
|
||||||
user.notificationPreferences.trending_markets.includes('email') &&
|
user.notificationPreferences.trending_markets.includes('email') &&
|
||||||
!user.weeklyTrendingEmailSent
|
!user.weeklyTrendingEmailSent
|
||||||
)
|
)
|
||||||
})
|
.slice(0, 90) // Send the emails out in batches
|
||||||
.slice(150) // 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(
|
log(
|
||||||
'Sending weekly trending emails to',
|
'Sending weekly trending emails to',
|
||||||
privateUsersToSendEmailsTo.length,
|
privateUsersToSendEmailsTo.length,
|
||||||
|
@ -70,42 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
!contract.groupSlugs?.includes('manifold-features') &&
|
!contract.groupSlugs?.includes('manifold-features') &&
|
||||||
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
||||||
)
|
)
|
||||||
.slice(0, 20)
|
.slice(0, 50)
|
||||||
log(
|
|
||||||
`Found ${trendingContracts.length} trending contracts:\n`,
|
|
||||||
trendingContracts.map((c) => c.question).join('\n ')
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: convert to Promise.all
|
const uniqueTrendingContracts = removeSimilarQuestions(
|
||||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
trendingContracts,
|
||||||
|
trendingContracts,
|
||||||
|
true
|
||||||
|
).slice(0, 20)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||||
if (!privateUser.email) {
|
if (!privateUser.email) {
|
||||||
log(`No email for ${privateUser.username}`)
|
log(`No email for ${privateUser.username}`)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
const contractsAvailableToSend = trendingContracts.filter((contract) => {
|
|
||||||
return !contract.uniqueBettorIds?.includes(privateUser.id)
|
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
|
||||||
})
|
privateUser.id
|
||||||
if (contractsAvailableToSend.length < numContractsToSend) {
|
)
|
||||||
log('not enough new, unbet-on contracts to send to user', 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({
|
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||||
weeklyTrendingEmailSent: true,
|
weeklyTrendingEmailSent: true,
|
||||||
})
|
})
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// choose random subset of contracts to send to user
|
// choose random subset of contracts to send to user
|
||||||
const contractsToSend = chooseRandomSubset(
|
const contractsToSend = chooseRandomSubset(
|
||||||
contractsAvailableToSend,
|
marketsAvailableToSend,
|
||||||
numContractsToSend
|
numContractsToSend
|
||||||
)
|
)
|
||||||
|
|
||||||
const user = await getUser(privateUser.id)
|
const user = await getUser(privateUser.id)
|
||||||
if (!user) continue
|
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 sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||||
await firestore.collection('private-users').doc(user.id).update({
|
await firestore.collection('private-users').doc(user.id).update({
|
||||||
weeklyTrendingEmailSent: true,
|
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
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
|
@ -116,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
shuffle(contracts, rng)
|
shuffle(contracts, rng)
|
||||||
return contracts.slice(0, count)
|
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',
|
||||||
|
]
|
||||||
|
|
|
@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
log('Found', contractsUsersBetOn.length, 'contracts')
|
|
||||||
let count = 0
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||||
const user = await getUser(privateUser.id)
|
const user = await getUser(privateUser.id)
|
||||||
// Don't send to a user unless they're over 5 days old
|
// 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 userBets = usersBets[privateUser.id] as Bet[]
|
||||||
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
||||||
userBets.some((bet) => bet.contractId === contract.id)
|
userBets.some((bet) => bet.contractId === contract.id)
|
||||||
|
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
(differences) => Math.abs(differences.profit)
|
(differences) => Math.abs(differences.profit)
|
||||||
).reverse()
|
).reverse()
|
||||||
|
|
||||||
log(
|
|
||||||
'Found',
|
|
||||||
investmentValueDifferences.length,
|
|
||||||
'investment differences for user',
|
|
||||||
privateUser.id
|
|
||||||
)
|
|
||||||
|
|
||||||
const [winningInvestments, losingInvestments] = partition(
|
const [winningInvestments, losingInvestments] = partition(
|
||||||
investmentValueDifferences.filter(
|
investmentValueDifferences.filter(
|
||||||
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
||||||
|
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
usersToContractsCreated[privateUser.id].length === 0
|
usersToContractsCreated[privateUser.id].length === 0
|
||||||
) {
|
) {
|
||||||
log(
|
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({
|
return await setEmailFlagAsSent(privateUser.id)
|
||||||
weeklyPortfolioUpdateEmailSent: true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
// Set the flag beforehand just to be safe
|
||||||
|
await setEmailFlagAsSent(privateUser.id)
|
||||||
await sendWeeklyPortfolioUpdateEmail(
|
await sendWeeklyPortfolioUpdateEmail(
|
||||||
user,
|
user,
|
||||||
privateUser,
|
privateUser,
|
||||||
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
||||||
performanceData
|
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 = {
|
export type PerContractInvestmentsData = {
|
||||||
questionTitle: string
|
questionTitle: string
|
||||||
questionUrl: string
|
questionUrl: string
|
||||||
|
|
|
@ -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()
|
|
|
@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
numericValue,
|
numericValue,
|
||||||
resolution,
|
resolution,
|
||||||
} = parsedReq
|
} = parsedReq
|
||||||
const MAX_QUESTION_CHARS = 100
|
|
||||||
const truncatedQuestion =
|
|
||||||
question.length > MAX_QUESTION_CHARS
|
|
||||||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
|
||||||
: question
|
|
||||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||||
|
|
||||||
let resolutionColor = 'text-primary'
|
let resolutionColor = 'text-primary'
|
||||||
|
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Generated Image</title>
|
<title>Generated Image</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
</head>
|
||||||
<style>
|
<style>
|
||||||
${getTemplateCss(theme, fontSize)}
|
${getTemplateCss(theme, fontSize)}
|
||||||
|
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row justify-between gap-12 pt-36">
|
<div class="flex flex-row justify-between gap-12 pt-36">
|
||||||
<div class="text-indigo-700 text-6xl leading-tight">
|
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
|
||||||
${truncatedQuestion}
|
${question}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
${
|
${
|
||||||
|
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="absolute bottom-16">
|
<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}
|
${metadata}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,8 +24,5 @@
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.7.1",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "4.8.2"
|
"typescript": "4.8.2"
|
||||||
},
|
|
||||||
"resolutions": {
|
|
||||||
"@types/react": "17.0.43"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
10
web/components/NoSEO.tsx
Normal file
10
web/components/NoSEO.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -15,7 +15,7 @@ export function SEO(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title} | Manifold Markets</title>
|
<title>{`${title} | Manifold Markets`}</title>
|
||||||
|
|
||||||
<meta
|
<meta
|
||||||
property="og:title"
|
property="og:title"
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
58
web/components/add-funds-modal.tsx
Normal file
58
web/components/add-funds-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,11 +1,12 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { SiteLink } from './site-link'
|
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
|
import { AddFundsModal } from './add-funds-modal'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
export function AmountInput(props: {
|
export function AmountInput(props: {
|
||||||
amount: number | undefined
|
amount: number | undefined
|
||||||
|
@ -35,6 +36,8 @@ export function AmountInput(props: {
|
||||||
onChange(isInvalid ? undefined : amount)
|
onChange(isInvalid ? undefined : amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col className={className}>
|
<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">
|
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<Input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
|
'pl-9',
|
||||||
error && 'input-error',
|
error && 'input-error',
|
||||||
'w-24 md:w-auto',
|
'w-24 md:w-auto',
|
||||||
inputClassName
|
inputClassName
|
||||||
|
@ -66,9 +69,16 @@ export function AmountInput(props: {
|
||||||
{error === 'Insufficient balance' ? (
|
{error === 'Insufficient balance' ? (
|
||||||
<>
|
<>
|
||||||
Not enough funds.
|
Not enough funds.
|
||||||
<span className="ml-1 text-indigo-500">
|
<button
|
||||||
<SiteLink href="/add-funds">Buy more?</SiteLink>
|
className="ml-1 text-indigo-500 hover:underline hover:decoration-indigo-400"
|
||||||
</span>
|
onClick={() => setAddFundsModalOpen(true)}
|
||||||
|
>
|
||||||
|
Buy more?
|
||||||
|
</button>
|
||||||
|
<AddFundsModal
|
||||||
|
open={addFundsModalOpen}
|
||||||
|
setOpen={setAddFundsModalOpen}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
error
|
error
|
||||||
|
|
|
@ -192,6 +192,7 @@ export function AnswerBetPanel(props: {
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
disabled={!!betDisabled}
|
disabled={!!betDisabled}
|
||||||
color={'indigo'}
|
color={'indigo'}
|
||||||
|
actionLabel="Buy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BetSignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
|
||||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||||
import { Linkify } from '../linkify'
|
import { Linkify } from '../linkify'
|
||||||
|
import { Input } from '../input'
|
||||||
|
|
||||||
export function AnswerItem(props: {
|
export function AnswerItem(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
|
@ -74,8 +75,8 @@ export function AnswerItem(props: {
|
||||||
<Row className="items-center justify-end gap-4 self-end sm:self-start">
|
<Row className="items-center justify-end gap-4 self-end sm:self-start">
|
||||||
{!wasResolvedTo &&
|
{!wasResolvedTo &&
|
||||||
(showChoice === 'checkbox' ? (
|
(showChoice === 'checkbox' ? (
|
||||||
<input
|
<Input
|
||||||
className="input input-bordered w-24 justify-self-end text-2xl"
|
className="w-24 justify-self-end !text-2xl"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={`${roundedProb}`}
|
placeholder={`${roundedProb}`}
|
||||||
maxLength={9}
|
maxLength={9}
|
||||||
|
|
|
@ -20,11 +20,11 @@ import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Linkify } from 'web/components/linkify'
|
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 { Button } from 'web/components/button'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
||||||
|
import { CATEGORY_COLORS } from '../charts/contract/choice'
|
||||||
|
import { useChartAnswers } from '../charts/contract/choice'
|
||||||
|
|
||||||
export function AnswersPanel(props: {
|
export function AnswersPanel(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
@ -38,6 +38,7 @@ export function AnswersPanel(props: {
|
||||||
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
||||||
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
||||||
|
|
||||||
const [winningAnswers, losingAnswers] = partition(
|
const [winningAnswers, losingAnswers] = partition(
|
||||||
|
@ -104,6 +105,10 @@ export function AnswersPanel(props: {
|
||||||
? 'checkbox'
|
? 'checkbox'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const colorSortedAnswer = useChartAnswers(contract).map(
|
||||||
|
(value, _index) => value.text
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-3">
|
<Col className="gap-3">
|
||||||
{(resolveOption || resolution) &&
|
{(resolveOption || resolution) &&
|
||||||
|
@ -128,7 +133,12 @@ export function AnswersPanel(props: {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{answerItems.map((item) => (
|
{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 && (
|
{hasZeroBetAnswers && !showAllAnswers && (
|
||||||
<Button
|
<Button
|
||||||
|
@ -147,9 +157,7 @@ export function AnswersPanel(props: {
|
||||||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' &&
|
{outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
|
||||||
tradingAllowed(contract) &&
|
|
||||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
|
||||||
<CreateAnswerPanel contract={contract} />
|
<CreateAnswerPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -174,15 +182,18 @@ export function AnswersPanel(props: {
|
||||||
function OpenAnswer(props: {
|
function OpenAnswer(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
answer: Answer
|
answer: Answer
|
||||||
|
colorIndex: number | undefined
|
||||||
}) {
|
}) {
|
||||||
const { answer, contract } = props
|
const { answer, contract, colorIndex } = props
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, text } = answer
|
||||||
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||||
const probPercent = formatPercent(prob)
|
const probPercent = formatPercent(prob)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const color =
|
||||||
|
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
|
||||||
|
|
||||||
return (
|
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">
|
<Modal open={open} setOpen={setOpen} position="center">
|
||||||
<AnswerBetPanel
|
<AnswerBetPanel
|
||||||
answer={answer}
|
answer={answer}
|
||||||
|
@ -193,40 +204,44 @@ function OpenAnswer(props: {
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<div
|
<Col
|
||||||
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(
|
className={clsx(
|
||||||
'text-2xl',
|
'bg-greyscale-1 relative w-full rounded-lg transition-all',
|
||||||
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
|
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{probPercent}
|
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
|
||||||
</span>
|
<Row>
|
||||||
<BuyButton
|
<Avatar
|
||||||
className={clsx(
|
className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
|
||||||
'btn-sm flex-initial !px-6 sm:flex',
|
username={username}
|
||||||
tradingAllowed(contract) ? '' : '!hidden'
|
avatarUrl={avatarUrl}
|
||||||
)}
|
/>
|
||||||
onClick={() => setOpen(true)}
|
<Linkify
|
||||||
|
className="text-md cursor-pointer whitespace-pre-line"
|
||||||
|
text={text}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
<Row className="gap-2">
|
||||||
</Col>
|
<div className="my-auto text-xl">{probPercent}</div>
|
||||||
|
{tradingAllowed(contract) && (
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
color="gray-outline"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="my-auto"
|
||||||
|
>
|
||||||
|
BUY
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Row>
|
</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>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { findBestMatch } from 'string-similarity'
|
import { findBestMatch } from 'string-similarity'
|
||||||
|
|
||||||
import { FreeResponseContract } from 'common/contract'
|
import { FreeResponseContract } from 'common/contract'
|
||||||
|
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
import { lowerCase } from 'lodash'
|
import { lowerCase } from 'lodash'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
|
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
<Col className="gap-4 rounded">
|
<Col className="gap-4 rounded">
|
||||||
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
||||||
<div className="mb-1">Add your answer</div>
|
<div className="mb-1">Add your answer</div>
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => changeAnswer(e.target.value)}
|
onChange={(e) => changeAnswer(e.target.value)}
|
||||||
className="textarea textarea-bordered w-full resize-none"
|
className="w-full"
|
||||||
placeholder="Type your answer..."
|
placeholder="Type your answer..."
|
||||||
rows={1}
|
rows={1}
|
||||||
maxLength={MAX_ANSWER_LENGTH}
|
maxLength={MAX_ANSWER_LENGTH}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { XIcon } from '@heroicons/react/solid'
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
export function MultipleChoiceAnswers(props: {
|
export function MultipleChoiceAnswers(props: {
|
||||||
answers: string[]
|
answers: string[]
|
||||||
|
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
|
||||||
{answers.map((answer, i) => (
|
{answers.map((answer, i) => (
|
||||||
<Row className="mb-2 items-center gap-2 align-middle">
|
<Row className="mb-2 items-center gap-2 align-middle">
|
||||||
{i + 1}.{' '}
|
{i + 1}.{' '}
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
value={answer}
|
value={answer}
|
||||||
onChange={(e) => setAnswer(i, e.target.value)}
|
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..."
|
placeholder="Type your answer..."
|
||||||
rows={1}
|
rows={1}
|
||||||
maxLength={MAX_ANSWER_LENGTH}
|
maxLength={MAX_ANSWER_LENGTH}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import clsx from 'clsx'
|
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 { MenuIcon } from '@heroicons/react/solid'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { XCircleIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Subtitle } from 'web/components/subtitle'
|
import { Subtitle } from 'web/components/subtitle'
|
||||||
import { keyBy } from 'lodash'
|
import { keyBy } from 'lodash'
|
||||||
import { XCircleIcon } from '@heroicons/react/outline'
|
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
import { updateUser } from 'web/lib/firebase/users'
|
import { updateUser } from 'web/lib/firebase/users'
|
||||||
import { leaveGroup } from 'web/lib/firebase/groups'
|
import { leaveGroup } from 'web/lib/firebase/groups'
|
||||||
|
|
|
@ -68,11 +68,11 @@ export function AuthProvider(props: {
|
||||||
}, [setAuthUser, serverUser])
|
}, [setAuthUser, serverUser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authUser != null) {
|
if (authUser) {
|
||||||
// Persist to local storage, to reduce login blink next time.
|
// Persist to local storage, to reduce login blink next time.
|
||||||
// Note: Cap on localStorage size is ~5mb
|
// Note: Cap on localStorage size is ~5mb
|
||||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
||||||
} else {
|
} else if (authUser === null) {
|
||||||
localStorage.removeItem(CACHED_USER_KEY)
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
}
|
}
|
||||||
}, [authUser])
|
}, [authUser])
|
||||||
|
|
62
web/components/badge-display.tsx
Normal file
62
web/components/badge-display.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
|
||||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
import { User } from 'web/lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
import { SellRow } from './sell-row'
|
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'
|
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||||
|
|
||||||
/** Button that opens BetPanel in a new modal */
|
/** Button that opens BetPanel in a new modal */
|
||||||
|
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
|
||||||
user: User
|
user: User
|
||||||
}) {
|
}) {
|
||||||
const { contract, user } = props
|
const { contract, user } = props
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||||
|
contract.id
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
|
||||||
contract={contract as CPMMBinaryContract}
|
contract={contract as CPMMBinaryContract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
mobileView={true}
|
mobileView={true}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { YesNoSelector } from './yes-no-selector'
|
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 { useUser } from 'web/hooks/use-user'
|
||||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
|
@ -34,14 +34,17 @@ export function BetInline(props: {
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||||
|
contract.id
|
||||||
|
)
|
||||||
|
|
||||||
const { newPool, newP } = getBinaryCpmmBetInfo(
|
const { newPool, newP } = getBinaryCpmmBetInfo(
|
||||||
outcome ?? 'YES',
|
outcome ?? 'YES',
|
||||||
amount ?? 0,
|
amount ?? 0,
|
||||||
contract,
|
contract,
|
||||||
undefined,
|
undefined,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
const resultProb = getCpmmProbability(newPool, newP)
|
const resultProb = getCpmmProbability(newPool, newP)
|
||||||
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
|
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
NoLabel,
|
NoLabel,
|
||||||
YesLabel,
|
YesLabel,
|
||||||
} from './outcome-label'
|
} from './outcome-label'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getContractBetMetrics, getProbability } from 'common/calculate'
|
||||||
import { useFocus } from 'web/hooks/use-focus'
|
import { useFocus } from 'web/hooks/use-focus'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
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 { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
import { ProbabilityOrNumericInput } from './probability-input'
|
import { ProbabilityOrNumericInput } from './probability-input'
|
||||||
import { track } from 'web/lib/service/analytics'
|
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 { LimitBets } from './limit-bets'
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
import { YesNoSelector } from './yes-no-selector'
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
|
@ -55,7 +55,9 @@ export function BetPanel(props: {
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const userBets = useUserContractBets(user?.id, contract.id)
|
const userBets = useUserContractBets(user?.id, contract.id)
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||||
|
contract.id
|
||||||
|
)
|
||||||
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
||||||
|
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
@ -86,12 +88,14 @@ export function BetPanel(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
/>
|
/>
|
||||||
<LimitOrderPanel
|
<LimitOrderPanel
|
||||||
hidden={!isLimitOrder}
|
hidden={!isLimitOrder}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -117,7 +121,9 @@ export function SimpleBetPanel(props: {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||||
|
contract.id
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
|
@ -142,6 +148,7 @@ export function SimpleBetPanel(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
onBuySuccess={onBetSuccess}
|
onBuySuccess={onBetSuccess}
|
||||||
/>
|
/>
|
||||||
<LimitOrderPanel
|
<LimitOrderPanel
|
||||||
|
@ -149,6 +156,7 @@ export function SimpleBetPanel(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
onBuySuccess={onBetSuccess}
|
onBuySuccess={onBetSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -167,13 +175,21 @@ export function SimpleBetPanel(props: {
|
||||||
export function BuyPanel(props: {
|
export function BuyPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
unfilledBets: Bet[]
|
unfilledBets: LimitBet[]
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
onBuySuccess?: () => void
|
onBuySuccess?: () => void
|
||||||
mobileView?: boolean
|
mobileView?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
|
const {
|
||||||
props
|
contract,
|
||||||
|
user,
|
||||||
|
unfilledBets,
|
||||||
|
balanceByUserId,
|
||||||
|
hidden,
|
||||||
|
onBuySuccess,
|
||||||
|
mobileView,
|
||||||
|
} = props
|
||||||
|
|
||||||
const initialProb = getProbability(contract)
|
const initialProb = getProbability(contract)
|
||||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
@ -261,7 +277,8 @@ export function BuyPanel(props: {
|
||||||
betAmount ?? 0,
|
betAmount ?? 0,
|
||||||
contract,
|
contract,
|
||||||
undefined,
|
undefined,
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
|
|
||||||
const [seeLimit, setSeeLimit] = useState(false)
|
const [seeLimit, setSeeLimit] = useState(false)
|
||||||
|
@ -395,6 +412,7 @@ export function BuyPanel(props: {
|
||||||
disabled={!!betDisabled || outcome === undefined}
|
disabled={!!betDisabled || outcome === undefined}
|
||||||
size="xl"
|
size="xl"
|
||||||
color={outcome === 'NO' ? 'red' : 'green'}
|
color={outcome === 'NO' ? 'red' : 'green'}
|
||||||
|
actionLabel="Wager"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
@ -415,6 +433,7 @@ export function BuyPanel(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
/>
|
/>
|
||||||
<LimitBets
|
<LimitBets
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -430,11 +449,19 @@ export function BuyPanel(props: {
|
||||||
function LimitOrderPanel(props: {
|
function LimitOrderPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
unfilledBets: Bet[]
|
unfilledBets: LimitBet[]
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
onBuySuccess?: () => void
|
onBuySuccess?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
|
const {
|
||||||
|
contract,
|
||||||
|
user,
|
||||||
|
unfilledBets,
|
||||||
|
balanceByUserId,
|
||||||
|
hidden,
|
||||||
|
onBuySuccess,
|
||||||
|
} = props
|
||||||
|
|
||||||
const initialProb = getProbability(contract)
|
const initialProb = getProbability(contract)
|
||||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
@ -580,7 +607,8 @@ function LimitOrderPanel(props: {
|
||||||
yesAmount,
|
yesAmount,
|
||||||
contract,
|
contract,
|
||||||
yesLimitProb ?? initialProb,
|
yesLimitProb ?? initialProb,
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
const yesReturnPercent = formatPercent(yesReturn)
|
const yesReturnPercent = formatPercent(yesReturn)
|
||||||
|
|
||||||
|
@ -594,7 +622,8 @@ function LimitOrderPanel(props: {
|
||||||
noAmount,
|
noAmount,
|
||||||
contract,
|
contract,
|
||||||
noLimitProb ?? initialProb,
|
noLimitProb ?? initialProb,
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
const noReturnPercent = formatPercent(noReturn)
|
const noReturnPercent = formatPercent(noReturn)
|
||||||
|
|
||||||
|
@ -829,15 +858,25 @@ export function SellPanel(props: {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [wasSubmitted, setWasSubmitted] = 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
|
// Sell all shares if remaining shares would be < 1
|
||||||
const isSellingAllShares = amount === Math.floor(shares)
|
const isSellingAllShares = amount === Math.floor(shares)
|
||||||
|
|
||||||
const sellQuantity = isSellingAllShares ? shares : amount
|
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() {
|
async function submitSell() {
|
||||||
if (!user || !amount) return
|
if (!user || !amount) return
|
||||||
|
|
||||||
|
@ -880,10 +919,26 @@ export function SellPanel(props: {
|
||||||
contract,
|
contract,
|
||||||
sellQuantity ?? 0,
|
sellQuantity ?? 0,
|
||||||
sharesOutcome,
|
sharesOutcome,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
|
const netProceeds = saleValue - loanPaid
|
||||||
|
const profit = saleValue - costBasis
|
||||||
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
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 openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
|
||||||
const [yesBets, noBets] = partition(
|
const [yesBets, noBets] = partition(
|
||||||
openUserBets,
|
openUserBets,
|
||||||
|
@ -923,14 +978,18 @@ export function SellPanel(props: {
|
||||||
label="Qty"
|
label="Qty"
|
||||||
error={error}
|
error={error}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
inputClassName="w-full"
|
inputClassName="w-full ml-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3 text-sm">
|
<Col className="mt-3 w-full gap-3 text-sm">
|
||||||
<Row className="items-center justify-between gap-2 text-gray-500">
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
||||||
Sale proceeds
|
Sale amount
|
||||||
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
||||||
</Row>
|
</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">
|
<Row className="items-center justify-between">
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||||
|
@ -941,24 +1000,33 @@ export function SellPanel(props: {
|
||||||
{format(resultProb)}
|
{format(resultProb)}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</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>
|
</Col>
|
||||||
|
|
||||||
<Spacer h={8} />
|
<Spacer h={8} />
|
||||||
|
|
||||||
<button
|
<WarningConfirmationButton
|
||||||
className={clsx(
|
marketType="binary"
|
||||||
'btn flex-1',
|
amount={undefined}
|
||||||
betDisabled
|
warning={warning}
|
||||||
? 'btn-disabled'
|
isSubmitting={isSubmitting}
|
||||||
: sharesOutcome === 'YES'
|
onSubmit={betDisabled ? undefined : submitSell}
|
||||||
? 'btn-primary'
|
disabled={!!betDisabled}
|
||||||
: 'border-none bg-red-400 hover:bg-red-500',
|
size="xl"
|
||||||
isSubmitting ? 'loading' : ''
|
color="blue"
|
||||||
)}
|
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
|
||||||
onClick={betDisabled ? undefined : submitSell}
|
/>
|
||||||
>
|
|
||||||
{isSubmitting ? 'Submitting...' : 'Submit sell'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
|
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import dayjs from 'dayjs'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
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 { User } from 'web/lib/firebase/users'
|
||||||
import {
|
import {
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
@ -17,6 +17,7 @@ import {
|
||||||
Contract,
|
Contract,
|
||||||
contractPath,
|
contractPath,
|
||||||
getBinaryProbPercent,
|
getBinaryProbPercent,
|
||||||
|
MAX_USER_BET_CONTRACTS_LOADED,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { sellBet } from 'web/lib/firebase/api'
|
import { sellBet } from 'web/lib/firebase/api'
|
||||||
|
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useUserBets } from 'web/hooks/use-user-bets'
|
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 { LimitBet } from 'common/bet'
|
||||||
import { Pagination } from './pagination'
|
import { Pagination } from './pagination'
|
||||||
import { LimitOrderTable } from './limit-bets'
|
import { LimitOrderTable } from './limit-bets'
|
||||||
|
@ -50,6 +51,7 @@ import {
|
||||||
usePersistentState,
|
usePersistentState,
|
||||||
} from 'web/hooks/use-persistent-state'
|
} from 'web/hooks/use-persistent-state'
|
||||||
import { safeLocalStorage } from 'web/lib/util/local'
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
|
import { ExclamationIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
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
|
return contractList ? keyBy(contractList, 'id') : undefined
|
||||||
}, [contractList])
|
}, [contractList])
|
||||||
|
|
||||||
|
const loadedPartialData =
|
||||||
|
userBets?.length === MAX_USER_BETS_LOADED ||
|
||||||
|
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
|
||||||
|
|
||||||
const [sort, setSort] = usePersistentState<BetSort>('newest', {
|
const [sort, setSort] = usePersistentState<BetSort>('newest', {
|
||||||
key: 'bets-list-sort',
|
key: 'bets-list-sort',
|
||||||
store: storageStore(safeLocalStorage()),
|
store: storageStore(safeLocalStorage()),
|
||||||
|
@ -160,26 +166,38 @@ export function BetsList(props: { user: User }) {
|
||||||
unsettled,
|
unsettled,
|
||||||
(c) => contractsMetrics[c.id].payout
|
(c) => contractsMetrics[c.id].payout
|
||||||
)
|
)
|
||||||
const currentNetInvestment = sumBy(
|
const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
|
||||||
unsettled,
|
|
||||||
(c) => contractsMetrics[c.id].netPayout
|
|
||||||
)
|
|
||||||
|
|
||||||
const investedProfitPercent =
|
const investedProfitPercent =
|
||||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
<Col>
|
||||||
<Row className="justify-between gap-4 sm:flex-row">
|
{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>
|
<Col>
|
||||||
<div className="text-greyscale-6 text-xs sm:text-sm">
|
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||||
Investment value
|
Investment value
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg">
|
<div className="text-lg">
|
||||||
{formatMoney(currentNetInvestment)}{' '}
|
{formatMoney(currentBetsValue)}{' '}
|
||||||
<ProfitBadge profitPercent={investedProfitPercent} />
|
<ProfitBadge profitPercent={investedProfitPercent} />
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</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">
|
<Row className="gap-2">
|
||||||
<select
|
<select
|
||||||
|
@ -206,7 +224,7 @@ export function BetsList(props: { user: User }) {
|
||||||
<option value="closeTime">Close date</option>
|
<option value="closeTime">Close date</option>
|
||||||
</select>
|
</select>
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Col>
|
||||||
|
|
||||||
<Col className="mt-6 divide-y">
|
<Col className="mt-6 divide-y">
|
||||||
{displayedContracts.length === 0 ? (
|
{displayedContracts.length === 0 ? (
|
||||||
|
@ -407,7 +425,9 @@ export function ContractBetsTable(props: {
|
||||||
const isNumeric = outcomeType === 'NUMERIC'
|
const isNumeric = outcomeType === 'NUMERIC'
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||||
|
contract.id
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
@ -456,6 +476,7 @@ export function ContractBetsTable(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isYourBet={isYourBets}
|
isYourBet={isYourBets}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -470,8 +491,10 @@ function BetRow(props: {
|
||||||
saleBet?: Bet
|
saleBet?: Bet
|
||||||
isYourBet: boolean
|
isYourBet: boolean
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[]
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
}) {
|
}) {
|
||||||
const { bet, saleBet, contract, isYourBet, unfilledBets } = props
|
const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
|
||||||
|
props
|
||||||
const {
|
const {
|
||||||
amount,
|
amount,
|
||||||
outcome,
|
outcome,
|
||||||
|
@ -499,9 +522,9 @@ function BetRow(props: {
|
||||||
} else if (contract.isResolved) {
|
} else if (contract.isResolved) {
|
||||||
return resolvedPayout(contract, bet)
|
return resolvedPayout(contract, bet)
|
||||||
} else {
|
} else {
|
||||||
return calculateSaleAmount(contract, bet, unfilledBets)
|
return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
|
||||||
}
|
}
|
||||||
}, [contract, bet, saleBet, unfilledBets])
|
}, [contract, bet, saleBet, unfilledBets, balanceByUserId])
|
||||||
|
|
||||||
const saleDisplay = isAnte ? (
|
const saleDisplay = isAnte ? (
|
||||||
'ANTE'
|
'ANTE'
|
||||||
|
@ -540,6 +563,7 @@ function BetRow(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bet={bet}
|
bet={bet}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
|
balanceByUserId={balanceByUserId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
@ -585,8 +609,9 @@ function SellButton(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bet: Bet
|
bet: Bet
|
||||||
unfilledBets: LimitBet[]
|
unfilledBets: LimitBet[]
|
||||||
|
balanceByUserId: { [userId: string]: number }
|
||||||
}) {
|
}) {
|
||||||
const { contract, bet, unfilledBets } = props
|
const { contract, bet, unfilledBets, balanceByUserId } = props
|
||||||
const { outcome, shares, loanAmount } = bet
|
const { outcome, shares, loanAmount } = bet
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
@ -600,10 +625,16 @@ function SellButton(props: {
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
outcome,
|
||||||
shares,
|
shares,
|
||||||
unfilledBets
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
)
|
)
|
||||||
|
|
||||||
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets)
|
const saleAmount = calculateSaleAmount(
|
||||||
|
contract,
|
||||||
|
bet,
|
||||||
|
unfilledBets,
|
||||||
|
balanceByUserId
|
||||||
|
)
|
||||||
const profit = saleAmount - bet.amount
|
const profit = saleAmount - bet.amount
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -612,7 +643,7 @@ function SellButton(props: {
|
||||||
label: 'Sell',
|
label: 'Sell',
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
submitBtn={{ className: 'btn-primary', label: 'Sell' }}
|
submitBtn={{ label: 'Sell', color: 'green' }}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
await sellBet({ contractId: contract.id, betId: bet.id })
|
await sellBet({ contractId: contract.id, betId: bet.id })
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { MouseEventHandler, ReactNode } from 'react'
|
import { MouseEventHandler, ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
|
||||||
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
export type ColorType =
|
export type ColorType =
|
||||||
|
@ -9,30 +10,12 @@ export type ColorType =
|
||||||
| 'indigo'
|
| 'indigo'
|
||||||
| 'yellow'
|
| 'yellow'
|
||||||
| 'gray'
|
| 'gray'
|
||||||
|
| 'gray-outline'
|
||||||
| 'gradient'
|
| 'gradient'
|
||||||
| 'gray-white'
|
| 'gray-white'
|
||||||
| 'highlight-blue'
|
| 'highlight-blue'
|
||||||
|
|
||||||
export function Button(props: {
|
const sizeClasses = {
|
||||||
className?: string
|
|
||||||
onClick?: MouseEventHandler<any> | undefined
|
|
||||||
children?: ReactNode
|
|
||||||
size?: SizeType
|
|
||||||
color?: ColorType
|
|
||||||
type?: 'button' | 'reset' | 'submit'
|
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
onClick,
|
|
||||||
size = 'md',
|
|
||||||
color = 'indigo',
|
|
||||||
type = 'button',
|
|
||||||
disabled = false,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
'2xs': 'px-2 py-1 text-xs',
|
'2xs': 'px-2 py-1 text-xs',
|
||||||
xs: 'px-2.5 py-1.5 text-sm',
|
xs: 'px-2.5 py-1.5 text-sm',
|
||||||
sm: 'px-3 py-2 text-sm',
|
sm: 'px-3 py-2 text-sm',
|
||||||
|
@ -40,14 +23,12 @@ export function Button(props: {
|
||||||
lg: 'px-4 py-2 text-base',
|
lg: 'px-4 py-2 text-base',
|
||||||
xl: 'px-6 py-2.5 text-base font-semibold',
|
xl: 'px-6 py-2.5 text-base font-semibold',
|
||||||
'2xl': 'px-6 py-3 text-xl font-semibold',
|
'2xl': 'px-6 py-3 text-xl font-semibold',
|
||||||
}[size]
|
}
|
||||||
|
|
||||||
return (
|
export function buttonClass(size: SizeType, color: ColorType | 'override') {
|
||||||
<button
|
return clsx(
|
||||||
type={type}
|
'font-md inline-flex items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
|
||||||
className={clsx(
|
sizeClasses[size],
|
||||||
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
|
|
||||||
sizeClasses,
|
|
||||||
color === 'green' &&
|
color === 'green' &&
|
||||||
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
|
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
|
||||||
color === 'red' &&
|
color === 'red' &&
|
||||||
|
@ -60,17 +41,46 @@ export function Button(props: {
|
||||||
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
|
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
|
||||||
color === 'gray' &&
|
color === 'gray' &&
|
||||||
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
|
'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' &&
|
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',
|
'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' &&
|
color === 'gray-white' &&
|
||||||
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
|
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
|
||||||
color === 'highlight-blue' &&
|
color === 'highlight-blue' &&
|
||||||
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
|
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
|
||||||
className
|
)
|
||||||
)}
|
}
|
||||||
disabled={disabled}
|
|
||||||
|
export function Button(props: {
|
||||||
|
className?: string
|
||||||
|
onClick?: MouseEventHandler<any> | undefined
|
||||||
|
children?: ReactNode
|
||||||
|
size?: SizeType
|
||||||
|
color?: ColorType | 'override'
|
||||||
|
type?: 'button' | 'reset' | 'submit'
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
size = 'md',
|
||||||
|
color = 'indigo',
|
||||||
|
type = 'button',
|
||||||
|
disabled = false,
|
||||||
|
loading,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={clsx(buttonClass(size, color), className)}
|
||||||
|
disabled={disabled || loading}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
{loading && <LoadingIndicator className={'mr-2 border-gray-500'} />}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
16
web/components/card.tsx
Normal file
16
web/components/card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
|
import { SwitchVerticalIcon } from '@heroicons/react/outline'
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
@ -16,16 +15,16 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { NoLabel, YesLabel } from '../outcome-label'
|
import { NoLabel, YesLabel } from '../outcome-label'
|
||||||
import { QRCode } from '../qr-code'
|
import { QRCode } from '../qr-code'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
|
||||||
import { AmountInput } from '../amount-input'
|
import { AmountInput } from '../amount-input'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
import { createMarket } from 'web/lib/firebase/api'
|
import { createMarket } from 'web/lib/firebase/api'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { FIXED_ANTE } from 'common/economy'
|
import { FIXED_ANTE } from 'common/economy'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { useTextEditor } from 'web/components/editor'
|
import { useTextEditor } from 'web/components/editor'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { CopyLinkButton } from '../copy-link-button'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
type challengeInfo = {
|
type challengeInfo = {
|
||||||
amount: number
|
amount: number
|
||||||
|
@ -154,9 +153,9 @@ function CreateChallengeForm(props: {
|
||||||
{contract ? (
|
{contract ? (
|
||||||
<span className="underline">{contract.question}</span>
|
<span className="underline">{contract.question}</span>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
placeholder="e.g. Will a Democrat be the next president?"
|
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}
|
autoFocus={true}
|
||||||
maxLength={MAX_QUESTION_LENGTH}
|
maxLength={MAX_QUESTION_LENGTH}
|
||||||
value={challengeInfo.question}
|
value={challengeInfo.question}
|
||||||
|
@ -302,16 +301,7 @@ function CreateChallengeForm(props: {
|
||||||
<Title className="!my-0" text="Challenge Created!" />
|
<Title className="!my-0" text="Challenge Created!" />
|
||||||
|
|
||||||
<div>Share the challenge using the link.</div>
|
<div>Share the challenge using the link.</div>
|
||||||
<button
|
<CopyLinkButton url={challengeSlug} />
|
||||||
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>
|
|
||||||
|
|
||||||
<QRCode url={challengeSlug} className="self-center" />
|
<QRCode url={challengeSlug} className="self-center" />
|
||||||
<Row className={'gap-1 text-gray-500'}>
|
<Row className={'gap-1 text-gray-500'}>
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { Charity } from 'common/charity'
|
||||||
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
||||||
import { manaToUSD } from '../../../common/util/format'
|
import { manaToUSD } from '../../../common/util/format'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
import { Card } from '../card'
|
||||||
|
|
||||||
export function CharityCard(props: { charity: Charity; match?: number }) {
|
export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
const { charity } = props
|
const { charity } = props
|
||||||
|
@ -15,8 +17,9 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
const raised = sumBy(txns, (txn) => txn.amount)
|
const raised = sumBy(txns, (txn) => txn.amount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/charity/${slug}`} passHref>
|
<Link href={`/charity/${slug}`}>
|
||||||
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md">
|
<a className="flex-1">
|
||||||
|
<Card className="!rounded-2xl">
|
||||||
<Row className="mt-6 mb-2">
|
<Row className="mt-6 mb-2">
|
||||||
{tags?.includes('Featured') && <FeaturedBadge />}
|
{tags?.includes('Featured') && <FeaturedBadge />}
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -29,8 +32,7 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
)}
|
)}
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<Col className="p-8">
|
||||||
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
|
|
||||||
<div className="line-clamp-4 text-sm">{preview}</div>
|
<div className="line-clamp-4 text-sm">{preview}</div>
|
||||||
{raised > 0 && (
|
{raised > 0 && (
|
||||||
<>
|
<>
|
||||||
|
@ -50,8 +52,9 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
</div>
|
</Card>
|
||||||
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,6 @@ import { BinaryContract } from 'common/contract'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import {
|
import {
|
||||||
TooltipProps,
|
TooltipProps,
|
||||||
MARGIN_X,
|
|
||||||
MARGIN_Y,
|
|
||||||
getDateRange,
|
getDateRange,
|
||||||
getRightmostVisibleDate,
|
getRightmostVisibleDate,
|
||||||
formatDateInRange,
|
formatDateInRange,
|
||||||
|
@ -20,6 +18,10 @@ import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
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[]) => {
|
const getBetPoints = (bets: Bet[]) => {
|
||||||
return sortBy(bets, (b) => b.createdTime).map((b) => ({
|
return sortBy(bets, (b) => b.createdTime).map((b) => ({
|
||||||
x: new Date(b.createdTime),
|
x: new Date(b.createdTime),
|
||||||
|
@ -29,9 +31,9 @@ const getBetPoints = (bets: Bet[]) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
|
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
|
||||||
const { data, mouseX, xScale } = props
|
const { data, x, xScale } = props
|
||||||
const [start, end] = xScale.domain()
|
const [start, end] = xScale.domain()
|
||||||
const d = xScale.invert(mouseX)
|
const d = xScale.invert(x)
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
|
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
|
||||||
|
@ -73,14 +75,15 @@ export const BinaryContractChart = (props: {
|
||||||
<SingleValueHistoryChart
|
<SingleValueHistoryChart
|
||||||
w={width}
|
w={width}
|
||||||
h={height}
|
h={height}
|
||||||
|
margin={MARGIN}
|
||||||
xScale={xScale}
|
xScale={xScale}
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
|
yKind="percent"
|
||||||
data={data}
|
data={data}
|
||||||
color="#11b981"
|
color="#11b981"
|
||||||
curve={curveStepAfter}
|
curve={curveStepAfter}
|
||||||
onMouseOver={onMouseOver}
|
onMouseOver={onMouseOver}
|
||||||
Tooltip={BinaryChartTooltip}
|
Tooltip={BinaryChartTooltip}
|
||||||
pct
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,6 @@ import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import {
|
import {
|
||||||
TooltipProps,
|
TooltipProps,
|
||||||
MARGIN_X,
|
|
||||||
MARGIN_Y,
|
|
||||||
getDateRange,
|
getDateRange,
|
||||||
getRightmostVisibleDate,
|
getRightmostVisibleDate,
|
||||||
formatPct,
|
formatPct,
|
||||||
|
@ -21,64 +19,65 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
|
|
||||||
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
|
export const CATEGORY_COLORS = [
|
||||||
const CATEGORY_COLORS = [
|
'#7eb0d5',
|
||||||
'#00b8dd',
|
'#fd7f6f',
|
||||||
'#eecafe',
|
'#b2e061',
|
||||||
'#874c62',
|
'#bd7ebe',
|
||||||
'#6457ca',
|
'#ffb55a',
|
||||||
'#f773ba',
|
'#ffee65',
|
||||||
'#9c6bbc',
|
'#beb9db',
|
||||||
'#a87744',
|
'#fdcce5',
|
||||||
'#af8a04',
|
'#8bd3c7',
|
||||||
'#bff9aa',
|
'#bddfb7',
|
||||||
'#f3d89d',
|
'#e2e3f3',
|
||||||
'#c9a0f5',
|
'#fafafa',
|
||||||
'#ff00e5',
|
'#9fcdeb',
|
||||||
'#9dc6f7',
|
'#d3d3d3',
|
||||||
'#824475',
|
'#b1a296',
|
||||||
'#d973cc',
|
'#e1bdb6',
|
||||||
'#bc6808',
|
'#f2dbc0',
|
||||||
'#056e70',
|
'#fae5d3',
|
||||||
'#677932',
|
'#c5e0ec',
|
||||||
'#00b287',
|
'#e0f0ff',
|
||||||
'#c8ab6c',
|
'#ffddcd',
|
||||||
'#a2fb7a',
|
'#fbd5e2',
|
||||||
'#f8db68',
|
'#f2e7e5',
|
||||||
'#14675a',
|
'#ffe7ba',
|
||||||
'#8288f4',
|
'#eed9c4',
|
||||||
'#fe1ca0',
|
'#ea9999',
|
||||||
'#ad6aff',
|
'#f9cb9c',
|
||||||
'#786306',
|
'#ffe599',
|
||||||
'#9bfbaf',
|
'#b6d7a8',
|
||||||
'#b00cf7',
|
'#a2c4c9',
|
||||||
'#2f7ec5',
|
'#9fc5e8',
|
||||||
'#4b998b',
|
'#b4a7d6',
|
||||||
'#42fa0e',
|
'#d5a6bd',
|
||||||
'#5b80a1',
|
'#e06666',
|
||||||
'#962d9d',
|
'#f6b26b',
|
||||||
'#3385ff',
|
'#ffd966',
|
||||||
'#48c5ab',
|
'#93c47d',
|
||||||
'#b2c873',
|
'#76a5af',
|
||||||
'#4cf9a4',
|
'#6fa8dc',
|
||||||
'#00ffff',
|
'#8e7cc3',
|
||||||
'#3cca73',
|
'#c27ba0',
|
||||||
'#99ae17',
|
'#cc0000',
|
||||||
'#7af5cf',
|
'#e69138',
|
||||||
'#52af45',
|
'#f1c232',
|
||||||
'#fbb80f',
|
'#6aa84f',
|
||||||
'#29971b',
|
'#45818e',
|
||||||
'#187c9a',
|
'#3d85c6',
|
||||||
'#00d539',
|
'#674ea7',
|
||||||
'#bbfa1a',
|
'#a64d79',
|
||||||
'#61f55c',
|
'#990000',
|
||||||
'#cabc03',
|
'#b45f06',
|
||||||
'#ff9000',
|
'#bf9000',
|
||||||
'#779100',
|
|
||||||
'#bcfd6f',
|
|
||||||
'#70a560',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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 = (
|
const getTrackedAnswers = (
|
||||||
contract: FreeResponseContract | MultipleChoiceContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
topN: number
|
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: {
|
export const ChoiceContractChart = (props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
|
@ -151,10 +159,7 @@ export const ChoiceContractChart = (props: {
|
||||||
}) => {
|
}) => {
|
||||||
const { contract, bets, width, height, onMouseOver } = props
|
const { contract, bets, width, height, onMouseOver } = props
|
||||||
const [start, end] = getDateRange(contract)
|
const [start, end] = getDateRange(contract)
|
||||||
const answers = useMemo(
|
const answers = useChartAnswers(contract)
|
||||||
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
|
|
||||||
[contract]
|
|
||||||
)
|
|
||||||
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
|
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
@ -178,9 +183,9 @@ export const ChoiceContractChart = (props: {
|
||||||
|
|
||||||
const ChoiceTooltip = useMemo(
|
const ChoiceTooltip = useMemo(
|
||||||
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
|
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
|
||||||
const { data, mouseX, xScale } = props
|
const { data, x, xScale } = props
|
||||||
const [start, end] = xScale.domain()
|
const [start, end] = xScale.domain()
|
||||||
const d = xScale.invert(mouseX)
|
const d = xScale.invert(x)
|
||||||
const legendItems = sortBy(
|
const legendItems = sortBy(
|
||||||
data.y.map((p, i) => ({
|
data.y.map((p, i) => ({
|
||||||
color: CATEGORY_COLORS[i],
|
color: CATEGORY_COLORS[i],
|
||||||
|
@ -211,14 +216,15 @@ export const ChoiceContractChart = (props: {
|
||||||
<MultiValueHistoryChart
|
<MultiValueHistoryChart
|
||||||
w={width}
|
w={width}
|
||||||
h={height}
|
h={height}
|
||||||
|
margin={MARGIN}
|
||||||
xScale={xScale}
|
xScale={xScale}
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
|
yKind="percent"
|
||||||
data={data}
|
data={data}
|
||||||
colors={CATEGORY_COLORS}
|
colors={CATEGORY_COLORS}
|
||||||
curve={curveStepAfter}
|
curve={curveStepAfter}
|
||||||
onMouseOver={onMouseOver}
|
onMouseOver={onMouseOver}
|
||||||
Tooltip={ChoiceTooltip}
|
Tooltip={ChoiceTooltip}
|
||||||
pct
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,13 @@ import { formatLargeNumber } from 'common/util/format'
|
||||||
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
|
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
|
||||||
import { NumericContract } from 'common/contract'
|
import { NumericContract } from 'common/contract'
|
||||||
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
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'
|
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 getNumericChartData = (contract: NumericContract) => {
|
||||||
const { totalShares, bucketCount, min, max } = contract
|
const { totalShares, bucketCount, min, max } = contract
|
||||||
const step = (max - min) / bucketCount
|
const step = (max - min) / bucketCount
|
||||||
|
@ -22,11 +26,11 @@ const getNumericChartData = (contract: NumericContract) => {
|
||||||
const NumericChartTooltip = (
|
const NumericChartTooltip = (
|
||||||
props: TooltipProps<number, DistributionPoint>
|
props: TooltipProps<number, DistributionPoint>
|
||||||
) => {
|
) => {
|
||||||
const { data, mouseX, xScale } = props
|
const { data, x, xScale } = props
|
||||||
const x = xScale.invert(mouseX)
|
const amount = xScale.invert(x)
|
||||||
return (
|
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>
|
<span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -48,6 +52,7 @@ export const NumericContractChart = (props: {
|
||||||
<DistributionChart
|
<DistributionChart
|
||||||
w={width}
|
w={width}
|
||||||
h={height}
|
h={height}
|
||||||
|
margin={MARGIN}
|
||||||
xScale={xScale}
|
xScale={xScale}
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
data={data}
|
data={data}
|
||||||
|
|
|
@ -11,8 +11,6 @@ import { PseudoNumericContract } from 'common/contract'
|
||||||
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
||||||
import {
|
import {
|
||||||
TooltipProps,
|
TooltipProps,
|
||||||
MARGIN_X,
|
|
||||||
MARGIN_Y,
|
|
||||||
getDateRange,
|
getDateRange,
|
||||||
getRightmostVisibleDate,
|
getRightmostVisibleDate,
|
||||||
formatDateInRange,
|
formatDateInRange,
|
||||||
|
@ -21,6 +19,10 @@ import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
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'
|
// mqp: note that we have an idiosyncratic version of 'log scale'
|
||||||
// contracts. the values are stored "linearly" and can include zero.
|
// contracts. the values are stored "linearly" and can include zero.
|
||||||
// as a result, we have to do some weird-looking stuff in this code
|
// 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 = (
|
const PseudoNumericChartTooltip = (
|
||||||
props: TooltipProps<Date, HistoryPoint<Bet>>
|
props: TooltipProps<Date, HistoryPoint<Bet>>
|
||||||
) => {
|
) => {
|
||||||
const { data, mouseX, xScale } = props
|
const { data, x, xScale } = props
|
||||||
const [start, end] = xScale.domain()
|
const [start, end] = xScale.domain()
|
||||||
const d = xScale.invert(mouseX)
|
const d = xScale.invert(x)
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
|
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
|
||||||
|
@ -95,6 +97,7 @@ export const PseudoNumericContractChart = (props: {
|
||||||
<SingleValueHistoryChart
|
<SingleValueHistoryChart
|
||||||
w={width}
|
w={width}
|
||||||
h={height}
|
h={height}
|
||||||
|
margin={MARGIN}
|
||||||
xScale={xScale}
|
xScale={xScale}
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
data={data}
|
data={data}
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
CurveFactory,
|
CurveFactory,
|
||||||
SeriesPoint,
|
SeriesPoint,
|
||||||
curveLinear,
|
curveLinear,
|
||||||
|
curveStepBefore,
|
||||||
|
curveStepAfter,
|
||||||
stack,
|
stack,
|
||||||
stackOrderReverse,
|
stackOrderReverse,
|
||||||
} from 'd3-shape'
|
} from 'd3-shape'
|
||||||
|
@ -14,34 +16,62 @@ import { range } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ContinuousScale,
|
ContinuousScale,
|
||||||
|
Margin,
|
||||||
SVGChart,
|
SVGChart,
|
||||||
AreaPath,
|
AreaPath,
|
||||||
AreaWithTopStroke,
|
AreaWithTopStroke,
|
||||||
Point,
|
Point,
|
||||||
|
SliceMarker,
|
||||||
|
TooltipParams,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
|
computeColorStops,
|
||||||
formatPct,
|
formatPct,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
import { useEvent } from 'web/hooks/use-event'
|
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 MultiPoint<T = unknown> = Point<Date, number[], T>
|
||||||
export type HistoryPoint<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 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 getTickValues = (min: number, max: number, n: number) => {
|
||||||
const step = (max - min) / (n - 1)
|
const step = (max - min) / (n - 1)
|
||||||
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
|
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[],
|
data: P[],
|
||||||
xScale: ContinuousScale<X>
|
xScale: ContinuousScale<X>
|
||||||
) => {
|
) => {
|
||||||
const bisect = bisector((p: P) => p.x)
|
const bisect = bisector((p: P) => p.x)
|
||||||
return (posX: number) => {
|
return (posX: number) => {
|
||||||
const x = xScale.invert(posX)
|
const x = xScale.invert(posX)
|
||||||
const item = data[bisect.left(data, x) - 1]
|
const i = bisect.left(data, x)
|
||||||
const result = item ? { ...item, x: posX } : undefined
|
const prev = data[i - 1] as P | undefined
|
||||||
return result
|
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
|
w: number
|
||||||
h: number
|
h: number
|
||||||
color: string
|
color: string
|
||||||
|
margin: Margin
|
||||||
xScale: ScaleContinuousNumeric<number, number>
|
xScale: ScaleContinuousNumeric<number, number>
|
||||||
yScale: ScaleContinuousNumeric<number, number>
|
yScale: ScaleContinuousNumeric<number, number>
|
||||||
curve?: CurveFactory
|
curve?: CurveFactory
|
||||||
onMouseOver?: (p: P | undefined) => void
|
onMouseOver?: (p: P | undefined) => void
|
||||||
Tooltip?: TooltipComponent<number, P>
|
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] =
|
const [viewXScale, setViewXScale] =
|
||||||
useState<ScaleContinuousNumeric<number, number>>()
|
useState<ScaleContinuousNumeric<number, number>>()
|
||||||
const xScale = viewXScale ?? props.xScale
|
const xScale = viewXScale ?? props.xScale
|
||||||
|
@ -72,7 +104,18 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
|
||||||
return { xAxis, yAxis }
|
return { xAxis, yAxis }
|
||||||
}, [w, xScale, yScale])
|
}, [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>) => {
|
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
|
||||||
if (ev.selection) {
|
if (ev.selection) {
|
||||||
|
@ -89,10 +132,13 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
|
||||||
<SVGChart
|
<SVGChart
|
||||||
w={w}
|
w={w}
|
||||||
h={h}
|
h={h}
|
||||||
|
margin={margin}
|
||||||
xAxis={xAxis}
|
xAxis={xAxis}
|
||||||
yAxis={yAxis}
|
yAxis={yAxis}
|
||||||
|
ttParams={ttParams}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onMouseOver={onMouseOver}
|
onMouseOver={onMouseOver}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
Tooltip={Tooltip}
|
Tooltip={Tooltip}
|
||||||
>
|
>
|
||||||
<AreaWithTopStroke
|
<AreaWithTopStroke
|
||||||
|
@ -112,15 +158,17 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
colors: readonly string[]
|
colors: readonly string[]
|
||||||
|
margin: Margin
|
||||||
xScale: ScaleTime<number, number>
|
xScale: ScaleTime<number, number>
|
||||||
yScale: ScaleContinuousNumeric<number, number>
|
yScale: ScaleContinuousNumeric<number, number>
|
||||||
|
yKind?: ValueKind
|
||||||
curve?: CurveFactory
|
curve?: CurveFactory
|
||||||
onMouseOver?: (p: P | undefined) => void
|
onMouseOver?: (p: P | undefined) => void
|
||||||
Tooltip?: TooltipComponent<Date, P>
|
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 [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
||||||
const xScale = viewXScale ?? props.xScale
|
const xScale = viewXScale ?? props.xScale
|
||||||
|
|
||||||
|
@ -131,29 +179,47 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
|
||||||
|
|
||||||
const { xAxis, yAxis } = useMemo(() => {
|
const { xAxis, yAxis } = useMemo(() => {
|
||||||
const [min, max] = yScale.domain()
|
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 xAxis = axisBottom<Date>(xScale).ticks(w / 100)
|
||||||
const yAxis = pct
|
const yAxis =
|
||||||
|
yKind === 'percent'
|
||||||
? axisLeft<number>(yScale)
|
? axisLeft<number>(yScale)
|
||||||
.tickValues(pctTickValues)
|
.tickValues(pctTickValues)
|
||||||
.tickFormat((n) => formatPct(n))
|
.tickFormat((n) => formatPct(n))
|
||||||
: axisLeft<number>(yScale)
|
: yKind === 'm$'
|
||||||
|
? axisLeft<number>(yScale)
|
||||||
|
.ticks(nTicks)
|
||||||
|
.tickFormat((n) => formatMoney(n))
|
||||||
|
: axisLeft<number>(yScale).ticks(nTicks)
|
||||||
return { xAxis, yAxis }
|
return { xAxis, yAxis }
|
||||||
}, [w, h, pct, xScale, yScale])
|
}, [w, h, yKind, xScale, yScale])
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
const d3Stack = stack<P, number>()
|
const d3Stack = stack<P, number>()
|
||||||
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
|
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
|
||||||
.value(({ y }, o) => y[o])
|
.value(({ y }, k) => y[k])
|
||||||
.order(stackOrderReverse)
|
.order(stackOrderReverse)
|
||||||
return d3Stack(data)
|
return d3Stack(data)
|
||||||
}, [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>) => {
|
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
|
||||||
if (ev.selection) {
|
if (ev.selection) {
|
||||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||||
|
|
||||||
setViewXScale(() =>
|
setViewXScale(() =>
|
||||||
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
|
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
|
||||||
)
|
)
|
||||||
|
@ -166,10 +232,13 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
|
||||||
<SVGChart
|
<SVGChart
|
||||||
w={w}
|
w={w}
|
||||||
h={h}
|
h={h}
|
||||||
|
margin={margin}
|
||||||
xAxis={xAxis}
|
xAxis={xAxis}
|
||||||
yAxis={yAxis}
|
yAxis={yAxis}
|
||||||
|
ttParams={ttParams}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onMouseOver={onMouseOver}
|
onMouseOver={onMouseOver}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
Tooltip={Tooltip}
|
Tooltip={Tooltip}
|
||||||
>
|
>
|
||||||
{series.map((s, i) => (
|
{series.map((s, i) => (
|
||||||
|
@ -191,66 +260,137 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
|
||||||
data: P[]
|
data: P[]
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
color: string
|
color: string | ((p: P) => string)
|
||||||
|
margin: Margin
|
||||||
xScale: ScaleTime<number, number>
|
xScale: ScaleTime<number, number>
|
||||||
yScale: ScaleContinuousNumeric<number, number>
|
yScale: ScaleContinuousNumeric<number, number>
|
||||||
|
yKind?: ValueKind
|
||||||
curve?: CurveFactory
|
curve?: CurveFactory
|
||||||
onMouseOver?: (p: P | undefined) => void
|
onMouseOver?: (p: P | undefined) => void
|
||||||
Tooltip?: TooltipComponent<Date, P>
|
Tooltip?: TooltipComponent<Date, P>
|
||||||
pct?: boolean
|
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 [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
||||||
const xScale = viewXScale ?? props.xScale
|
const xScale = viewXScale ?? props.xScale
|
||||||
|
|
||||||
const px = useCallback((p: P) => xScale(p.x), [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 py1 = useCallback((p: P) => yScale(p.y), [yScale])
|
||||||
|
|
||||||
const { xAxis, yAxis } = useMemo(() => {
|
const { xAxis, yAxis } = useMemo(() => {
|
||||||
const [min, max] = yScale.domain()
|
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 xAxis = axisBottom<Date>(xScale).ticks(w / 100)
|
||||||
const yAxis = pct
|
const yAxis =
|
||||||
|
yKind === 'percent'
|
||||||
? axisLeft<number>(yScale)
|
? axisLeft<number>(yScale)
|
||||||
.tickValues(pctTickValues)
|
.tickValues(pctTickValues)
|
||||||
.tickFormat((n) => formatPct(n))
|
.tickFormat((n) => formatPct(n))
|
||||||
: axisLeft<number>(yScale)
|
: yKind === 'm$'
|
||||||
|
? axisLeft<number>(yScale)
|
||||||
|
.ticks(nTicks)
|
||||||
|
.tickFormat((n) => formatMoney(n))
|
||||||
|
: axisLeft<number>(yScale).ticks(nTicks)
|
||||||
return { xAxis, yAxis }
|
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>) => {
|
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
|
||||||
if (ev.selection) {
|
if (ev.selection) {
|
||||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||||
setViewXScale(() =>
|
const newViewXScale = xScale
|
||||||
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
|
.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 {
|
} else {
|
||||||
setViewXScale(undefined)
|
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 (
|
return (
|
||||||
<SVGChart
|
<SVGChart
|
||||||
w={w}
|
w={w}
|
||||||
h={h}
|
h={h}
|
||||||
|
margin={margin}
|
||||||
xAxis={xAxis}
|
xAxis={xAxis}
|
||||||
yAxis={yAxis}
|
yAxis={yAxis}
|
||||||
|
ttParams={
|
||||||
|
mouse ? { x: mouse.x, y: mouse.y, data: mouse.data } : undefined
|
||||||
|
}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onMouseOver={onMouseOver}
|
onMouseOver={onMouseOver}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
Tooltip={Tooltip}
|
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
|
<AreaWithTopStroke
|
||||||
color={color}
|
color={typeof color === 'string' ? color : `url(#${gradientId})`}
|
||||||
data={data}
|
data={data}
|
||||||
px={px}
|
px={px}
|
||||||
py0={py0}
|
py0={py0}
|
||||||
py1={py1}
|
py1={py1}
|
||||||
curve={curve ?? curveLinear}
|
curve={curve ?? curveLinear}
|
||||||
/>
|
/>
|
||||||
|
{mouse && (
|
||||||
|
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
|
||||||
|
)}
|
||||||
</SVGChart>
|
</SVGChart>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,4 @@
|
||||||
import {
|
import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
|
||||||
ReactNode,
|
|
||||||
SVGProps,
|
|
||||||
memo,
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import { pointer, select } from 'd3-selection'
|
import { pointer, select } from 'd3-selection'
|
||||||
import { Axis, AxisScale } from 'd3-axis'
|
import { Axis, AxisScale } from 'd3-axis'
|
||||||
import { brushX, D3BrushEvent } from 'd3-brush'
|
import { brushX, D3BrushEvent } from 'd3-brush'
|
||||||
|
@ -17,6 +9,7 @@ import clsx from 'clsx'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
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 }
|
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 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 type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
|
||||||
|
|
||||||
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
|
export type Margin = {
|
||||||
export const MARGIN_X = MARGIN.right + MARGIN.left
|
top: number
|
||||||
export const MARGIN_Y = MARGIN.top + MARGIN.bottom
|
right: number
|
||||||
const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px`
|
bottom: number
|
||||||
const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})`
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
|
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
|
||||||
const { h, axis } = props
|
const { h, axis } = props
|
||||||
|
@ -55,8 +49,6 @@ export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (axisRef.current != null) {
|
if (axisRef.current != null) {
|
||||||
select(axisRef.current)
|
select(axisRef.current)
|
||||||
.transition()
|
|
||||||
.duration(250)
|
|
||||||
.call(axis)
|
.call(axis)
|
||||||
.call((g) =>
|
.call((g) =>
|
||||||
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
|
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 AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
|
||||||
|
|
||||||
export const AreaWithTopStroke = <P,>(props: {
|
export const AreaWithTopStroke = <P,>(props: {
|
||||||
color: string
|
|
||||||
data: P[]
|
data: P[]
|
||||||
|
color: string
|
||||||
px: number | ((p: P) => number)
|
px: number | ((p: P) => number)
|
||||||
py0: number | ((p: P) => number)
|
py0: number | ((p: P) => number)
|
||||||
py1: number | ((p: P) => number)
|
py1: number | ((p: P) => number)
|
||||||
curve: CurveFactory
|
curve: CurveFactory
|
||||||
}) => {
|
}) => {
|
||||||
const { color, data, px, py0, py1, curve } = props
|
const { data, color, px, py0, py1, curve } = props
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<AreaPath
|
<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: {
|
export const SVGChart = <X, TT>(props: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
|
margin: Margin
|
||||||
xAxis: Axis<X>
|
xAxis: Axis<X>
|
||||||
yAxis: Axis<number>
|
yAxis: Axis<number>
|
||||||
|
ttParams: TooltipParams<TT> | undefined
|
||||||
onSelect?: (ev: D3BrushEvent<any>) => void
|
onSelect?: (ev: D3BrushEvent<any>) => void
|
||||||
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
|
onMouseOver?: (mouseX: number, mouseY: number) => void
|
||||||
|
onMouseLeave?: () => void
|
||||||
Tooltip?: TooltipComponent<X, TT>
|
Tooltip?: TooltipComponent<X, TT>
|
||||||
}) => {
|
}) => {
|
||||||
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
|
const {
|
||||||
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
|
children,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
margin,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
ttParams,
|
||||||
|
onSelect,
|
||||||
|
onMouseOver,
|
||||||
|
onMouseLeave,
|
||||||
|
Tooltip,
|
||||||
|
} = props
|
||||||
const tooltipMeasure = useMeasureSize()
|
const tooltipMeasure = useMeasureSize()
|
||||||
const overlayRef = useRef<SVGGElement>(null)
|
const overlayRef = useRef<SVGGElement>(null)
|
||||||
const innerW = w - MARGIN_X
|
const innerW = w - (margin.left + margin.right)
|
||||||
const innerH = h - MARGIN_Y
|
const innerH = h - (margin.top + margin.bottom)
|
||||||
const clipPathId = useMemo(() => nanoid(), [])
|
const clipPathId = useMemo(() => nanoid(), [])
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const justSelected = useRef(false)
|
const justSelected = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -155,7 +184,7 @@ export const SVGChart = <X, TT>(props: {
|
||||||
if (!justSelected.current) {
|
if (!justSelected.current) {
|
||||||
justSelected.current = true
|
justSelected.current = true
|
||||||
onSelect(ev)
|
onSelect(ev)
|
||||||
setMouse(undefined)
|
onMouseLeave?.()
|
||||||
if (overlayRef.current) {
|
if (overlayRef.current) {
|
||||||
select(overlayRef.current).call(brush.clear)
|
select(overlayRef.current).call(brush.clear)
|
||||||
}
|
}
|
||||||
|
@ -171,43 +200,49 @@ export const SVGChart = <X, TT>(props: {
|
||||||
.select('.selection')
|
.select('.selection')
|
||||||
.attr('shape-rendering', 'null')
|
.attr('shape-rendering', 'null')
|
||||||
}
|
}
|
||||||
}, [innerW, innerH, onSelect])
|
}, [innerW, innerH, onSelect, onMouseLeave])
|
||||||
|
|
||||||
const onPointerMove = (ev: React.PointerEvent) => {
|
const onPointerMove = (ev: React.PointerEvent) => {
|
||||||
if (ev.pointerType === 'mouse' && onMouseOver) {
|
if (ev.pointerType === 'mouse' && onMouseOver) {
|
||||||
const [x, y] = pointer(ev)
|
const [x, y] = pointer(ev)
|
||||||
const data = onMouseOver(x, y)
|
onMouseOver(x, y)
|
||||||
if (data !== undefined) {
|
|
||||||
setMouse({ x, y, data })
|
|
||||||
} else {
|
|
||||||
setMouse(undefined)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
const onPointerLeave = () => {
|
||||||
setMouse(undefined)
|
onMouseLeave?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
{mouse && Tooltip && (
|
{ttParams && Tooltip && (
|
||||||
<TooltipContainer
|
<TooltipContainer
|
||||||
setElem={tooltipMeasure.setElem}
|
setElem={tooltipMeasure.setElem}
|
||||||
|
margin={margin}
|
||||||
pos={getTooltipPosition(
|
pos={getTooltipPosition(
|
||||||
mouse.x,
|
ttParams.x,
|
||||||
mouse.y,
|
ttParams.y,
|
||||||
innerW,
|
innerW,
|
||||||
innerH,
|
innerH,
|
||||||
tooltipMeasure.width,
|
tooltipMeasure.width ?? 140,
|
||||||
tooltipMeasure.height
|
tooltipMeasure.height ?? 35,
|
||||||
|
isMobile ?? false
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
xScale={xAxis.scale()}
|
xScale={xAxis.scale()}
|
||||||
mouseX={mouse.x}
|
x={ttParams.x}
|
||||||
mouseY={mouse.y}
|
y={ttParams.y}
|
||||||
data={mouse.data}
|
data={ttParams.data}
|
||||||
/>
|
/>
|
||||||
</TooltipContainer>
|
</TooltipContainer>
|
||||||
)}
|
)}
|
||||||
|
@ -215,10 +250,11 @@ export const SVGChart = <X, TT>(props: {
|
||||||
<clipPath id={clipPathId}>
|
<clipPath id={clipPathId}>
|
||||||
<rect x={0} y={0} width={innerW} height={innerH} />
|
<rect x={0} y={0} width={innerW} height={innerH} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
<g transform={MARGIN_XFORM}>
|
<g transform={`translate(${margin.left}, ${margin.top})`}>
|
||||||
<XAxis axis={xAxis} w={innerW} h={innerH} />
|
<XAxis axis={xAxis} w={innerW} h={innerH} />
|
||||||
<YAxis axis={yAxis} w={innerW} h={innerH} />
|
<YAxis axis={yAxis} w={innerW} h={innerH} />
|
||||||
<g clipPath={`url(#${clipPathId})`}>{children}</g>
|
<g clipPath={`url(#${clipPathId})`}>{children}</g>
|
||||||
|
{!isMobile ? (
|
||||||
<g
|
<g
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
x="0"
|
x="0"
|
||||||
|
@ -231,6 +267,17 @@ export const SVGChart = <X, TT>(props: {
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width={innerW}
|
||||||
|
height={innerH}
|
||||||
|
fill="transparent"
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onPointerLeave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
@ -244,41 +291,45 @@ export const getTooltipPosition = (
|
||||||
mouseY: number,
|
mouseY: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
containerHeight: number,
|
containerHeight: number,
|
||||||
tooltipWidth?: number,
|
tooltipWidth: number,
|
||||||
tooltipHeight?: number
|
tooltipHeight: number,
|
||||||
|
isMobile: boolean
|
||||||
) => {
|
) => {
|
||||||
let left = mouseX + 12
|
let left = mouseX + 12
|
||||||
let bottom = containerHeight - mouseY + 12
|
let bottom = !isMobile
|
||||||
|
? containerHeight - mouseY + 12
|
||||||
|
: containerHeight - tooltipHeight + 12
|
||||||
if (tooltipWidth != null) {
|
if (tooltipWidth != null) {
|
||||||
const overflow = left + tooltipWidth - containerWidth
|
const overflow = left + tooltipWidth - containerWidth
|
||||||
if (overflow > 0) {
|
if (overflow > 0) {
|
||||||
left -= overflow
|
left -= overflow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooltipHeight != null) {
|
if (tooltipHeight != null) {
|
||||||
const overflow = tooltipHeight - mouseY
|
const overflow = tooltipHeight - mouseY
|
||||||
if (overflow > 0) {
|
if (overflow > 0) {
|
||||||
bottom -= overflow
|
bottom -= overflow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { left, bottom }
|
return { left, bottom }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TooltipProps<X, T> = {
|
export type TooltipParams<T> = { x: number; y: number; data: T }
|
||||||
mouseX: number
|
export type TooltipProps<X, T> = TooltipParams<T> & {
|
||||||
mouseY: number
|
|
||||||
xScale: ContinuousScale<X>
|
xScale: ContinuousScale<X>
|
||||||
data: T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
|
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
|
||||||
export const TooltipContainer = (props: {
|
export const TooltipContainer = (props: {
|
||||||
setElem: (e: HTMLElement | null) => void
|
setElem: (e: HTMLElement | null) => void
|
||||||
pos: TooltipPosition
|
pos: TooltipPosition
|
||||||
|
margin: Margin
|
||||||
className?: string
|
className?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const { setElem, pos, className, children } = props
|
const { setElem, pos, margin, className, children } = props
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setElem}
|
ref={setElem}
|
||||||
|
@ -286,13 +337,43 @@ export const TooltipContainer = (props: {
|
||||||
className,
|
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'
|
'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}
|
{children}
|
||||||
</div>
|
</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) => {
|
export const getDateRange = (contract: Contract) => {
|
||||||
const { createdTime, closeTime, resolutionTime } = contract
|
const { createdTime, closeTime, resolutionTime } = contract
|
||||||
const isClosed = !!closeTime && Date.now() > closeTime
|
const isClosed = !!closeTime && Date.now() > closeTime
|
||||||
|
|
|
@ -6,9 +6,13 @@ import dayjs from 'dayjs'
|
||||||
import { formatPercent } from 'common/util/format'
|
import { formatPercent } from 'common/util/format'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { HistoryPoint, SingleValueHistoryChart } from './generic-charts'
|
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'
|
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 getPoints = (startDate: number, dailyValues: number[]) => {
|
||||||
const startDateDayJs = dayjs(startDate)
|
const startDateDayJs = dayjs(startDate)
|
||||||
return dailyValues.map((y, i) => ({
|
return dailyValues.map((y, i) => ({
|
||||||
|
@ -18,8 +22,8 @@ const getPoints = (startDate: number, dailyValues: number[]) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
|
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
|
||||||
const { data, mouseX, xScale } = props
|
const { data, x, xScale } = props
|
||||||
const d = xScale.invert(mouseX)
|
const d = xScale.invert(x)
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
|
<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 DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
|
||||||
const { data, mouseX, xScale } = props
|
const { data, x, xScale } = props
|
||||||
const d = xScale.invert(mouseX)
|
const d = xScale.invert(x)
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
|
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
|
||||||
|
@ -63,12 +67,13 @@ export function DailyChart(props: {
|
||||||
<SingleValueHistoryChart
|
<SingleValueHistoryChart
|
||||||
w={width}
|
w={width}
|
||||||
h={height}
|
h={height}
|
||||||
|
margin={MARGIN}
|
||||||
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
|
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
|
||||||
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
|
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
|
||||||
|
yKind={pct ? 'percent' : 'amount'}
|
||||||
data={data}
|
data={data}
|
||||||
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
|
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
|
||||||
color="#11b981"
|
color="#11b981"
|
||||||
pct={pct}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SizedContainer>
|
</SizedContainer>
|
||||||
|
|
|
@ -138,16 +138,6 @@ export function CommentInputTextArea(props: {
|
||||||
<LoadingIndicator spinnerClassName="border-gray-500" />
|
<LoadingIndicator spinnerClassName="border-gray-500" />
|
||||||
)}
|
)}
|
||||||
</TextEditor>
|
</TextEditor>
|
||||||
<Row>
|
|
||||||
{!user && (
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm mt-2 normal-case"
|
|
||||||
onClick={submitComment}
|
|
||||||
>
|
|
||||||
Add my comment
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,16 +16,18 @@ export function ConfirmationButton(props: {
|
||||||
}
|
}
|
||||||
cancelBtn?: {
|
cancelBtn?: {
|
||||||
label?: string
|
label?: string
|
||||||
className?: string
|
color?: ColorType
|
||||||
}
|
}
|
||||||
submitBtn?: {
|
submitBtn?: {
|
||||||
label?: string
|
label?: string
|
||||||
className?: string
|
color?: ColorType
|
||||||
|
isSubmitting?: boolean
|
||||||
}
|
}
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onOpenChanged?: (isOpen: boolean) => void
|
onOpenChanged?: (isOpen: boolean) => void
|
||||||
onSubmitWithSuccess?: () => Promise<boolean>
|
onSubmitWithSuccess?: () => Promise<boolean>
|
||||||
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
openModalBtn,
|
openModalBtn,
|
||||||
|
@ -35,6 +37,7 @@ export function ConfirmationButton(props: {
|
||||||
children,
|
children,
|
||||||
onOpenChanged,
|
onOpenChanged,
|
||||||
onSubmitWithSuccess,
|
onSubmitWithSuccess,
|
||||||
|
disabled,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
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">
|
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
||||||
{children}
|
{children}
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<div
|
<Button
|
||||||
className={clsx('btn', cancelBtn?.className)}
|
color={cancelBtn?.color ?? 'gray-white'}
|
||||||
onClick={() => updateOpen(false)}
|
onClick={() => updateOpen(false)}
|
||||||
>
|
>
|
||||||
{cancelBtn?.label ?? 'Cancel'}
|
{cancelBtn?.label ?? 'Cancel'}
|
||||||
</div>
|
</Button>
|
||||||
<div
|
<Button
|
||||||
className={clsx('btn', submitBtn?.className)}
|
color={submitBtn?.color ?? 'blue'}
|
||||||
onClick={
|
onClick={
|
||||||
onSubmitWithSuccess
|
onSubmitWithSuccess
|
||||||
? () =>
|
? () =>
|
||||||
|
@ -66,15 +69,22 @@ export function ConfirmationButton(props: {
|
||||||
)
|
)
|
||||||
: onSubmit
|
: onSubmit
|
||||||
}
|
}
|
||||||
|
loading={submitBtn?.isSubmitting}
|
||||||
>
|
>
|
||||||
{submitBtn?.label ?? 'Submit'}
|
{submitBtn?.label ?? 'Submit'}
|
||||||
</div>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className={clsx(openModalBtn.className)}
|
className={openModalBtn.className}
|
||||||
onClick={() => updateOpen(true)}
|
onClick={() => {
|
||||||
|
if (disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateOpen(true)
|
||||||
|
}}
|
||||||
disabled={openModalBtn.disabled}
|
disabled={openModalBtn.disabled}
|
||||||
color={openModalBtn.color}
|
color={openModalBtn.color}
|
||||||
size={openModalBtn.size}
|
size={openModalBtn.size}
|
||||||
|
@ -90,18 +100,11 @@ export function ResolveConfirmationButton(props: {
|
||||||
onResolve: () => void
|
onResolve: () => void
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
openModalButtonClass?: string
|
openModalButtonClass?: string
|
||||||
submitButtonClass?: string
|
|
||||||
color?: ColorType
|
color?: ColorType
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { onResolve, isSubmitting, openModalButtonClass, color, disabled } =
|
||||||
onResolve,
|
props
|
||||||
isSubmitting,
|
|
||||||
openModalButtonClass,
|
|
||||||
submitButtonClass,
|
|
||||||
color,
|
|
||||||
disabled,
|
|
||||||
} = props
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationButton
|
<ConfirmationButton
|
||||||
openModalBtn={{
|
openModalBtn={{
|
||||||
|
@ -116,7 +119,8 @@ export function ResolveConfirmationButton(props: {
|
||||||
}}
|
}}
|
||||||
submitBtn={{
|
submitBtn={{
|
||||||
label: 'Resolve',
|
label: 'Resolve',
|
||||||
className: clsx('border-none', submitButtonClass),
|
color: color,
|
||||||
|
isSubmitting,
|
||||||
}}
|
}}
|
||||||
onSubmit={onResolve}
|
onSubmit={onResolve}
|
||||||
>
|
>
|
||||||
|
|
|
@ -41,12 +41,15 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
import { Title } from './title'
|
import { Title } from './title'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
export const SORTS = [
|
export const SORTS = [
|
||||||
{ label: 'Newest', value: 'newest' },
|
{ label: 'Newest', value: 'newest' },
|
||||||
{ label: 'Trending', value: 'score' },
|
{ label: 'Trending', value: 'score' },
|
||||||
{ label: 'Daily trending', value: 'daily-score' },
|
{ label: 'Daily trending', value: 'daily-score' },
|
||||||
{ label: '24h volume', value: '24-hour-vol' },
|
{ label: '24h volume', value: '24-hour-vol' },
|
||||||
|
{ label: 'Most popular', value: 'most-popular' },
|
||||||
|
{ label: 'Liquidity', value: 'liquidity' },
|
||||||
{ label: 'Last updated', value: 'last-updated' },
|
{ label: 'Last updated', value: 'last-updated' },
|
||||||
{ label: 'Closing soon', value: 'close-date' },
|
{ label: 'Closing soon', value: 'close-date' },
|
||||||
{ label: 'Resolve date', value: 'resolve-date' },
|
{ label: 'Resolve date', value: 'resolve-date' },
|
||||||
|
@ -436,16 +439,16 @@ function ContractSearchControls(props: {
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => updateQuery(e.target.value)}
|
onChange={(e) => updateQuery(e.target.value)}
|
||||||
onBlur={trackCallback('search', { query: query })}
|
onBlur={trackCallback('search', { query: query })}
|
||||||
placeholder={'Search'}
|
placeholder="Search"
|
||||||
className="input input-bordered w-full"
|
className="w-full"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{!isMobile && (
|
{!isMobile && !query && (
|
||||||
<SearchFilters
|
<SearchFilters
|
||||||
filter={filter}
|
filter={filter}
|
||||||
selectFilter={selectFilter}
|
selectFilter={selectFilter}
|
||||||
|
@ -456,7 +459,7 @@ function ContractSearchControls(props: {
|
||||||
includeProbSorts={includeProbSorts}
|
includeProbSorts={includeProbSorts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isMobile && (
|
{isMobile && !query && (
|
||||||
<>
|
<>
|
||||||
<MobileSearchBar
|
<MobileSearchBar
|
||||||
children={
|
children={
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { CurrencyDollarIcon } from '@heroicons/react/outline'
|
import { CurrencyDollarIcon } from '@heroicons/react/outline'
|
||||||
import { Contract } from 'common/contract'
|
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 { 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() {
|
export function BountiedContractBadge() {
|
||||||
return (
|
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
|
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, showAmount } = props
|
const { contract, showAmount } = props
|
||||||
const { openCommentBounties } = contract
|
const { openCommentBounties } = contract
|
||||||
if (!openCommentBounties) return <div />
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!openCommentBounties && !showAmount) return <></>
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const bountiesClosed =
|
||||||
|
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
|
||||||
|
|
||||||
|
if (!openCommentBounties) {
|
||||||
|
if (bountiesClosed) return <></>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<>
|
||||||
text={CommentBountiesTooltipText(
|
{modal}
|
||||||
contract.creatorName,
|
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentBountiesTooltipText = (
|
const tooltip = `${contract.creatorName} may award ${formatMoney(
|
||||||
creator: string,
|
|
||||||
openCommentBounties: number
|
|
||||||
) =>
|
|
||||||
`${creator} may award ${formatMoney(
|
|
||||||
COMMENT_BOUNTY_AMOUNT
|
COMMENT_BOUNTY_AMOUNT
|
||||||
)} for good comments. ${formatMoney(
|
)} for good comments. ${formatMoney(
|
||||||
openCommentBounties
|
openCommentBounties
|
||||||
)} currently available.`
|
)} 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
Loading…
Reference in New Issue
Block a user