Merge branch 'main' into atlas2

This commit is contained in:
Austin Chen 2022-07-03 16:06:20 -07:00
commit e5c3c782e6
60 changed files with 893 additions and 757 deletions

View File

@ -18,15 +18,24 @@ import {
getDpmProbabilityAfterSale, getDpmProbabilityAfterSale,
} from './calculate-dpm' } from './calculate-dpm'
import { calculateFixedPayout } from './calculate-fixed-payouts' import { calculateFixedPayout } from './calculate-fixed-payouts'
import { Contract, BinaryContract, FreeResponseContract } from './contract' import {
Contract,
BinaryContract,
FreeResponseContract,
PseudoNumericContract,
} from './contract'
export function getProbability(contract: BinaryContract) { export function getProbability(
contract: BinaryContract | PseudoNumericContract
) {
return contract.mechanism === 'cpmm-1' return contract.mechanism === 'cpmm-1'
? getCpmmProbability(contract.pool, contract.p) ? getCpmmProbability(contract.pool, contract.p)
: getDpmProbability(contract.totalShares) : getDpmProbability(contract.totalShares)
} }
export function getInitialProbability(contract: BinaryContract) { export function getInitialProbability(
contract: BinaryContract | PseudoNumericContract
) {
if (contract.initialProbability) return contract.initialProbability if (contract.initialProbability) return contract.initialProbability
if (contract.mechanism === 'dpm-2' || (contract as any).totalShares) if (contract.mechanism === 'dpm-2' || (contract as any).totalShares)
@ -65,7 +74,9 @@ export function calculateShares(
} }
export function calculateSaleAmount(contract: Contract, bet: Bet) { export function calculateSaleAmount(contract: Contract, bet: Bet) {
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue
: calculateDpmSaleAmount(contract, bet) : calculateDpmSaleAmount(contract, bet)
} }
@ -87,7 +98,9 @@ export function getProbabilityAfterSale(
} }
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateFixedPayout(contract, bet, outcome) ? calculateFixedPayout(contract, bet, outcome)
: calculateDpmPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome)
} }
@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
const outcome = contract.resolution const outcome = contract.resolution
if (!outcome) throw new Error('Contract not resolved') if (!outcome) throw new Error('Contract not resolved')
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateFixedPayout(contract, bet, outcome) ? calculateFixedPayout(contract, bet, outcome)
: calculateDpmPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome)
} }
@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const profit = payout + saleValue + redeemed - totalInvested const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100 const profitPercent = (profit / totalInvested) * 100
const hasShares = Object.values(totalShares).some( const hasShares = Object.values(totalShares).some((shares) => shares > 0)
(shares) => shares > 0
)
return { return {
invested: Math.max(0, currentInvested), invested: Math.max(0, currentInvested),

View File

@ -2,9 +2,10 @@ import { Answer } from './answer'
import { Fees } from './fees' import { Fees } from './fees'
export type AnyMechanism = DPM | CPMM export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric)
| (DPM & Binary) | (DPM & Binary)
| (DPM & FreeResponse) | (DPM & FreeResponse)
| (DPM & Numeric) | (DPM & Numeric)
@ -33,7 +34,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
isResolved: boolean isResolved: boolean
resolutionTime?: number // When the contract creator resolved the market resolutionTime?: number // When the contract creator resolved the market
resolution?: string resolution?: string
resolutionProbability?: number, resolutionProbability?: number
closeEmailsSent?: number closeEmailsSent?: number
@ -45,6 +46,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse export type FreeResponseContract = Contract & FreeResponse
export type DPMContract = Contract & DPM export type DPMContract = Contract & DPM
@ -75,6 +77,18 @@ export type Binary = {
resolution?: resolution resolution?: resolution
} }
export type PseudoNumeric = {
outcomeType: 'PSEUDO_NUMERIC'
min: number
max: number
isLogScale: boolean
resolutionValue?: number
// same as binary market; map everything to probability
initialProbability: number
resolutionProbability?: number
}
export type FreeResponse = { export type FreeResponse = {
outcomeType: 'FREE_RESPONSE' outcomeType: 'FREE_RESPONSE'
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
@ -94,7 +108,7 @@ export type Numeric = {
export type outcomeType = AnyOutcomeType['outcomeType'] export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const
export const MAX_QUESTION_LENGTH = 480 export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000 export const MAX_DESCRIPTION_LENGTH = 10000

View File

@ -14,6 +14,7 @@ import {
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
NumericContract, NumericContract,
PseudoNumericContract,
} from './contract' } from './contract'
import { noFees } from './fees' import { noFees } from './fees'
import { addObjects } from './util/object' import { addObjects } from './util/object'
@ -32,7 +33,7 @@ export type BetInfo = {
export const getNewBinaryCpmmBetInfo = ( export const getNewBinaryCpmmBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: CPMMBinaryContract, contract: CPMMBinaryContract | PseudoNumericContract,
loanAmount: number loanAmount: number
) => { ) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase( const { shares, newPool, newP, fees } = calculateCpmmPurchase(

View File

@ -7,6 +7,7 @@ import {
FreeResponse, FreeResponse,
Numeric, Numeric,
outcomeType, outcomeType,
PseudoNumeric,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags } from './util/parse' import { parseTags } from './util/parse'
@ -27,7 +28,8 @@ export function getNewContract(
// used for numeric markets // used for numeric markets
bucketCount: number, bucketCount: number,
min: number, min: number,
max: number max: number,
isLogScale: boolean
) { ) {
const tags = parseTags( const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
@ -37,6 +39,8 @@ export function getNewContract(
const propsByOutcomeType = const propsByOutcomeType =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: outcomeType === 'PSEUDO_NUMERIC'
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC' : outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max) ? getNumericProps(ante, bucketCount, min, max)
: getFreeAnswerProps(ante) : getFreeAnswerProps(ante)
@ -111,6 +115,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
return system return system
} }
const getPseudoNumericCpmmProps = (
initialProb: number,
ante: number,
min: number,
max: number,
isLogScale: boolean
) => {
const system: CPMM & PseudoNumeric = {
...getBinaryCpmmProps(initialProb, ante),
outcomeType: 'PSEUDO_NUMERIC',
min,
max,
isLogScale,
}
return system
}
const getFreeAnswerProps = (ante: number) => { const getFreeAnswerProps = (ante: number) => {
const system: DPM & FreeResponse = { const system: DPM & FreeResponse = {
mechanism: 'dpm-2', mechanism: 'dpm-2',

View File

@ -1,7 +1,12 @@
import { sumBy, groupBy, mapValues } from 'lodash' import { sumBy, groupBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet' import { Bet, NumericBet } from './bet'
import { Contract, CPMMBinaryContract, DPMContract } from './contract' import {
Contract,
CPMMBinaryContract,
DPMContract,
PseudoNumericContract,
} from './contract'
import { Fees } from './fees' import { Fees } from './fees'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { import {
@ -56,7 +61,11 @@ export const getPayouts = (
}, },
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { if (
contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
) {
return getFixedPayouts( return getFixedPayouts(
outcome, outcome,
contract, contract,
@ -76,7 +85,7 @@ export const getPayouts = (
export const getFixedPayouts = ( export const getFixedPayouts = (
outcome: string | undefined, outcome: string | undefined,
contract: CPMMBinaryContract, contract: CPMMBinaryContract | PseudoNumericContract,
bets: Bet[], bets: Bet[],
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[],
resolutionProbability?: number resolutionProbability?: number

45
common/pseudo-numeric.ts Normal file
View File

@ -0,0 +1,45 @@
import { BinaryContract, PseudoNumericContract } from './contract'
import { formatLargeNumber, formatPercent } from './util/format'
export function formatNumericProbability(
p: number,
contract: PseudoNumericContract
) {
const value = getMappedValue(contract)(p)
return formatLargeNumber(value)
}
export const getMappedValue =
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
if (contract.outcomeType === 'BINARY') return p
const { min, max, isLogScale } = contract
if (isLogScale) {
const logValue = p * Math.log10(max - min)
return 10 ** logValue + min
}
return p * (max - min) + min
}
export const getFormattedMappedValue =
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
if (contract.outcomeType === 'BINARY') return formatPercent(p)
const value = getMappedValue(contract)(p)
return formatLargeNumber(value)
}
export const getPseudoProbability = (
value: number,
min: number,
max: number,
isLogScale = false
) => {
if (isLogScale) {
return Math.log10(value - min) / Math.log10(max - min)
}
return (value - min) / (max - min)
}

View File

@ -21,11 +21,16 @@ service cloud.firestore {
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
// only one referral allowed per user
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId']) .hasOnly(['referredByUserId'])
&& !("referredByUserId" in resource.data); // only one referral allowed per user
&& !("referredByUserId" in resource.data)
// user can't refer themselves
&& (resource.data.id != request.resource.data.referredByUserId)
// user can't refer someone who referred them quid pro quo
&& get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id;
} }
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {

View File

@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
### For local development ### For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 0. `$ brew install java` 1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
1. `$ brew install java`
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options) 3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
4. `$ mkdir firestore_export` to create a folder to store the exported database 4. `$ mkdir firestore_export` to create a folder to store the exported database

View File

@ -12,7 +12,7 @@
"start": "yarn shell", "start": "yarn shell",
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
@ -23,7 +23,6 @@
"main": "functions/src/index.js", "main": "functions/src/index.js",
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"fetch": "1.1.0",
"firebase-admin": "10.0.0", "firebase-admin": "10.0.0",
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "lodash": "4.17.21",

View File

@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
if ( if (
contract.mechanism !== 'cpmm-1' || contract.mechanism !== 'cpmm-1' ||
contract.outcomeType !== 'BINARY' (contract.outcomeType !== 'BINARY' &&
contract.outcomeType !== 'PSEUDO_NUMERIC')
) )
return { status: 'error', message: 'Invalid contract' } return { status: 'error', message: 'Invalid contract' }

View File

@ -18,46 +18,63 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as firestore from '@google-cloud/firestore' import * as firestore from '@google-cloud/firestore'
const client = new firestore.v1.FirestoreAdminClient() import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client'
const bucket = 'gs://manifold-firestore-backup' export const backupDbCore = async (
client: FirestoreAdminClient,
project: string,
bucket: string
) => {
const name = client.databasePath(project, '(default)')
const outputUriPrefix = `gs://${bucket}`
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
// NOTE: Subcollections are not backed up by default
const collectionIds = [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'transactions',
'users',
'bets',
'comments',
'follows',
'followers',
'answers',
'txns',
'manalinks',
'liquidity',
'stats',
'cache',
'latency',
'views',
'notifications',
'portfolioHistory',
'folds',
]
return await client.exportDocuments({ name, outputUriPrefix, collectionIds })
}
export const backupDb = functions.pubsub export const backupDb = functions.pubsub
.schedule('every 24 hours') .schedule('every 24 hours')
.onRun((_context) => { .onRun(async (_context) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT try {
if (projectId == null) { const client = new firestore.v1.FirestoreAdminClient()
throw new Error('No project ID environment variable set.') const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
if (project == null) {
throw new Error('No project ID environment variable set.')
}
const responses = await backupDbCore(
client,
project,
'manifold-firestore-backup'
)
const response = responses[0]
console.log(`Operation Name: ${response['name']}`)
} catch (err) {
console.error(err)
throw new Error('Export operation failed')
} }
const databaseName = client.databasePath(projectId, '(default)')
return client
.exportDocuments({
name: databaseName,
outputUriPrefix: bucket,
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
// NOTE: Subcollections are not backed up by default
collectionIds: [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'users',
'bets',
'comments',
'followers',
'answers',
'txns',
],
})
.then((responses) => {
const response = responses[0]
console.log(`Operation Name: ${response['name']}`)
})
.catch((err) => {
console.error(err)
throw new Error('Export operation failed')
})
}) })

View File

@ -1,17 +0,0 @@
import * as admin from 'firebase-admin'
import fetch from './fetch'
export const callCloudFunction = (functionName: string, data: unknown = {}) => {
const projectId = admin.instanceId().app.options.projectId
const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}`
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ data }),
}).then((response) => response.json())
}

View File

@ -28,6 +28,7 @@ import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group' import { Group, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric'
const bodySchema = z.object({ const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH), question: z.string().min(1).max(MAX_QUESTION_LENGTH),
@ -45,19 +46,31 @@ const binarySchema = z.object({
initialProb: z.number().min(1).max(99), initialProb: z.number().min(1).max(99),
}) })
const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER)
const numericSchema = z.object({ const numericSchema = z.object({
min: z.number(), min: finite(),
max: z.number(), max: finite(),
initialValue: finite(),
isLogScale: z.boolean().optional(),
}) })
export const createmarket = newEndpoint({}, async (req, auth) => { export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } = const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body) validate(bodySchema, req.body)
let min, max, initialProb let min, max, initialProb, isLogScale
if (outcomeType === 'NUMERIC') {
;({ min, max } = validate(numericSchema, req.body)) if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') let initialValue
;({ min, max, initialValue, isLogScale } = validate(
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue < min || initialValue > max)
throw new APIError(400, 'Invalid range.')
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, req.body))
@ -121,7 +134,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
tags ?? [], tags ?? [],
NUMERIC_BUCKET_COUNT, NUMERIC_BUCKET_COUNT,
min ?? 0, min ?? 0,
max ?? 0 max ?? 0,
isLogScale ?? false
) )
if (ante) await chargeUser(user.id, ante, true) if (ante) await chargeUser(user.id, ante, true)
@ -130,7 +144,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const providerId = user.id const providerId = user.id
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`) .collection(`contracts/${contract.id}/liquidity`)
.doc() .doc()

View File

@ -6,8 +6,13 @@ import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees' import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format' import {
formatLargeNumber,
formatMoney,
formatPercent,
} from '../../common/util/format'
import { getValueFromBucket } from '../../common/calculate-dpm' import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
@ -101,6 +106,17 @@ const toDisplayResolution = (
return display || resolution return display || resolution
} }
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract
return resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? getProbability(contract),
contract
)
}
if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A' if (resolution === 'CANCEL') return 'N/A'

View File

@ -1,9 +0,0 @@
let fetchRequest: typeof fetch
try {
fetchRequest = fetch
} catch {
fetchRequest = require('node-fetch')
}
export default fetchRequest

View File

@ -1,25 +0,0 @@
import * as functions from 'firebase-functions'
import { callCloudFunction } from './call-cloud-function'
export const keepAwake = functions.pubsub
.schedule('every 1 minutes')
.onRun(async () => {
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
await sleep(30)
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
})
const sleep = (seconds: number) => {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}

View File

@ -41,10 +41,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
log('Inside main transaction.') log('Inside main transaction.')
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 [contractSnap, userSnap] = await Promise.all([ const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
trans.get(contractDoc),
trans.get(userDoc),
])
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.')
log('Loaded user and contract snapshots.') log('Loaded user and contract snapshots.')
@ -70,7 +67,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
const { outcome } = validate(binarySchema, req.body) const { outcome } = validate(binarySchema, req.body)
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { } else if (
(outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') &&
mechanism == 'cpmm-1'
) {
const { outcome } = validate(binarySchema, req.body) const { outcome } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {

View File

@ -16,7 +16,11 @@ export const redeemShares = async (userId: string, contractId: string) => {
return { status: 'error', message: 'Invalid contract' } return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') const { mechanism, outcomeType } = contract
if (
!(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') ||
mechanism !== 'cpmm-1'
)
return { status: 'success' } return { status: 'success' }
const betsSnap = await transaction.get( const betsSnap = await transaction.get(

View File

@ -27,7 +27,7 @@ const bodySchema = z.object({
const binarySchema = z.object({ const binarySchema = z.object({
outcome: z.enum(RESOLUTIONS), outcome: z.enum(RESOLUTIONS),
probabilityInt: z.number().gte(0).lt(100).optional(), probabilityInt: z.number().gte(0).lte(100).optional(),
}) })
const freeResponseSchema = z.union([ const freeResponseSchema = z.union([
@ -39,7 +39,7 @@ const freeResponseSchema = z.union([
resolutions: z.array( resolutions: z.array(
z.object({ z.object({
answer: z.number().int().nonnegative(), answer: z.number().int().nonnegative(),
pct: z.number().gte(0).lt(100), pct: z.number().gte(0).lte(100),
}) })
), ),
}), }),
@ -53,7 +53,19 @@ const numericSchema = z.object({
value: z.number().optional(), value: z.number().optional(),
}) })
const pseudoNumericSchema = z.union([
z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
value: z.number(),
probabilityInt: z.number().gte(0).lte(100),
}),
])
const opts = { secrets: ['MAILGUN_KEY'] } const opts = { secrets: ['MAILGUN_KEY'] }
export const resolvemarket = newEndpoint(opts, async (req, auth) => { export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body) const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid const userId = auth.uid
@ -221,12 +233,18 @@ const sendResolutionEmails = async (
function getResolutionParams(contract: Contract, body: string) { function getResolutionParams(contract: Contract, body: string) {
const { outcomeType } = contract const { outcomeType } = contract
if (outcomeType === 'NUMERIC') { if (outcomeType === 'NUMERIC') {
return { return {
...validate(numericSchema, body), ...validate(numericSchema, body),
resolutions: undefined, resolutions: undefined,
probabilityInt: undefined, probabilityInt: undefined,
} }
} else if (outcomeType === 'PSEUDO_NUMERIC') {
return {
...validate(pseudoNumericSchema, body),
resolutions: undefined,
}
} else if (outcomeType === 'FREE_RESPONSE') { } else if (outcomeType === 'FREE_RESPONSE') {
const freeResponseParams = validate(freeResponseSchema, body) const freeResponseParams = validate(freeResponseSchema, body)
const { outcome } = freeResponseParams const { outcome } = freeResponseParams

View File

@ -0,0 +1,16 @@
import * as firestore from '@google-cloud/firestore'
import { getServiceAccountCredentials } from './script-init'
import { backupDbCore } from '../backup-db'
async function backupDb() {
const credentials = getServiceAccountCredentials()
const projectId = credentials.project_id
const client = new firestore.v1.FirestoreAdminClient({ credentials })
const bucket = 'manifold-firestore-backup'
const resp = await backupDbCore(client, projectId, bucket)
console.log(`Operation: ${resp[0]['name']}`)
}
if (require.main === module) {
backupDb().then(() => process.exit())
}

View File

@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => {
} }
} }
export const initAdmin = (env?: string) => { export const getServiceAccountCredentials = (env?: string) => {
env = env || getFirebaseActiveProject(process.cwd()) env = env || getFirebaseActiveProject(process.cwd())
if (env == null) { if (env == null) {
console.error( throw new Error(
"Couldn't find active Firebase project; did you do `firebase use <alias>?`" "Couldn't find active Firebase project; did you do `firebase use <alias>?`"
) )
return
} }
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
const keyPath = process.env[envVar] const keyPath = process.env[envVar]
if (keyPath == null) { if (keyPath == null) {
console.error( throw new Error(
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.`
) )
return
} }
console.log(`Initializing connection to ${env} Firebase...`)
/* eslint-disable-next-line @typescript-eslint/no-var-requires */ /* eslint-disable-next-line @typescript-eslint/no-var-requires */
const serviceAccount = require(keyPath) return require(keyPath)
admin.initializeApp({ }
export const initAdmin = (env?: string) => {
const serviceAccount = getServiceAccountCredentials(env)
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount), credential: admin.credential.cert(serviceAccount),
}) })
} }

View File

@ -1,53 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { User } from '../../../common/user'
import { batchedWaitAll } from '../../../common/util/promise'
import { Contract } from '../../../common/contract'
import { updateWordScores } from '../update-recommendations'
import { computeFeed } from '../update-feed'
import { getFeedContracts, getTaggedContracts } from '../get-feed-data'
import { CATEGORY_LIST } from '../../../common/categories'
const firestore = admin.firestore()
async function updateFeed() {
console.log('Updating feed')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
const feedContracts = await getFeedContracts()
const users = await getValues<User>(
firestore.collection('users').where('username', '==', 'JamesGrugett')
)
await batchedWaitAll(
users.map((user) => async () => {
console.log('Updating recs for', user.username)
await updateWordScores(user, contracts)
console.log('Updating feed for', user.username)
await computeFeed(user, feedContracts)
})
)
console.log('Updating feed categories!')
await batchedWaitAll(
users.map((user) => async () => {
for (const category of CATEGORY_LIST) {
const contracts = await getTaggedContracts(category)
const feed = await computeFeed(user, contracts)
await firestore
.collection(`private-users/${user.id}/cache`)
.doc(`feed-${category}`)
.set({ feed })
}
})
)
}
if (require.main === module) {
updateFeed().then(() => process.exit())
}

View File

@ -21,11 +21,11 @@ export const sellbet = 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 betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const [contractSnap, userSnap, betSnap] = await Promise.all([ const [contractSnap, userSnap, betSnap] = await transaction.getAll(
transaction.get(contractDoc), contractDoc,
transaction.get(userDoc), userDoc,
transaction.get(betDoc), betDoc
]) )
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.')
if (!betSnap.exists) throw new APIError(400, 'Bet not found.') if (!betSnap.exists) throw new APIError(400, 'Bet not found.')

View File

@ -24,9 +24,8 @@ 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, userBets] = await Promise.all([ const [[contractSnap, userSnap], userBets] = await Promise.all([
transaction.get(contractDoc), transaction.getAll(contractDoc, userDoc),
transaction.get(userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
]) ])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')

View File

@ -1,220 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
import { logInterpolation } from '../../common/util/math'
import { DAY_MS } from '../../common/util/time'
import {
getProbability,
getOutcomeProbability,
getTopAnswer,
} from '../../common/calculate'
import { User } from '../../common/user'
import {
getContractScore,
MAX_FEED_CONTRACTS,
} from '../../common/recommended-contracts'
import { callCloudFunction } from './call-cloud-function'
import {
getFeedContracts,
getRecentBetsAndComments,
getTaggedContracts,
} from './get-feed-data'
import { CATEGORY_LIST } from '../../common/categories'
const firestore = admin.firestore()
const BATCH_SIZE = 30
const MAX_BATCHES = 50
const getUserBatches = async () => {
const users = shuffle(await getValues<User>(firestore.collection('users')))
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += BATCH_SIZE) {
userBatches.push(users.slice(i, i + BATCH_SIZE))
}
console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length)
return userBatches.slice(0, MAX_BATCHES)
}
export const updateFeed = functions.pubsub
.schedule('every 60 minutes')
.onRun(async () => {
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map((users) =>
callCloudFunction('updateFeedBatch', { users })
)
)
console.log('updating category feed')
await Promise.all(
CATEGORY_LIST.map((category) =>
callCloudFunction('updateCategoryFeed', {
category,
})
)
)
})
export const updateFeedBatch = functions.https.onCall(
async (data: { users: User[] }) => {
const { users } = data
const contracts = await getFeedContracts()
const feeds = await getNewFeeds(users, contracts)
await Promise.all(
zip(users, feeds).map(([user, feed]) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
getUserCacheCollection(user!).doc('feed').set({ feed })
)
)
}
)
export const updateCategoryFeed = functions.https.onCall(
async (data: { category: string }) => {
const { category } = data
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map(async (users) => {
await callCloudFunction('updateCategoryFeedBatch', {
users,
category,
})
})
)
}
)
export const updateCategoryFeedBatch = functions.https.onCall(
async (data: { users: User[]; category: string }) => {
const { users, category } = data
const contracts = await getTaggedContracts(category)
const feeds = await getNewFeeds(users, contracts)
await Promise.all(
zip(users, feeds).map(([user, feed]) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed })
)
)
}
)
const getNewFeeds = async (users: User[], contracts: Contract[]) => {
const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts)))
const contractIds = uniq(flatten(feeds).map((c) => c.id))
const data = await Promise.all(contractIds.map(getRecentBetsAndComments))
const dataByContractId = zipObject(contractIds, data)
return feeds.map((feed) =>
feed.map((contract) => {
return { contract, ...dataByContractId[contract.id] }
})
)
}
const getUserCacheCollection = (user: User) =>
firestore.collection(`private-users/${user.id}/cache`)
export const computeFeed = async (user: User, contracts: Contract[]) => {
const userCacheCollection = getUserCacheCollection(user)
const [wordScores, lastViewedTime] = await Promise.all([
getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')),
getValue<{ [contractId: string]: number }>(
userCacheCollection.doc('lastViewTime')
),
]).then((dicts) => dicts.map((dict) => dict ?? {}))
const scoredContracts = contracts.map((contract) => {
const score = scoreContract(
contract,
wordScores,
lastViewedTime[contract.id]
)
return [contract, score] as [Contract, number]
})
const sortedContracts = sortBy(
scoredContracts,
([_, score]) => score
).reverse()
// console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score))
return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c)
}
function scoreContract(
contract: Contract,
wordScores: { [word: string]: number },
viewTime: number | undefined
) {
const recommendationScore = getContractScore(contract, wordScores)
const activityScore = getActivityScore(contract, viewTime)
// const lastViewedScore = getLastViewedScore(viewTime)
return recommendationScore * activityScore
}
function getActivityScore(contract: Contract, viewTime: number | undefined) {
const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract
const hasNewComments =
lastCommentTime && (!viewTime || lastCommentTime > viewTime)
const newCommentScore = hasNewComments ? 1 : 0.5
const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime)
const commentDaysAgo = timeSinceLastComment / DAY_MS
const commentTimeScore =
0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo))
const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime)
const betDaysAgo = timeSinceLastBet / DAY_MS
const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo))
let prob = 0.5
if (outcomeType === 'BINARY') {
prob = getProbability(contract)
} else if (outcomeType === 'FREE_RESPONSE') {
const topAnswer = getTopAnswer(contract)
if (topAnswer)
prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id))
}
const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25
const probScore = 0.5 + frac * 0.5
const { volume24Hours, volume7Days } = contract
const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1)
const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume)
const score =
newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore
// Map score to [0.5, 1] since no recent activty is not a deal breaker.
const mappedScore = 0.5 + 0.5 * score
const newMappedScore = 0.7 + 0.3 * score
const isNew = Date.now() < contract.createdTime + DAY_MS
return isNew ? newMappedScore : mappedScore
}
// function getLastViewedScore(viewTime: number | undefined) {
// if (viewTime === undefined) {
// return 1
// }
// const daysAgo = (Date.now() - viewTime) / DAY_MS
// if (daysAgo < 0.5) {
// const frac = logInterpolation(0, 0.5, daysAgo)
// return 0.5 + 0.25 * frac
// }
// const frac = logInterpolation(0.5, 14, daysAgo)
// return 0.75 + 0.25 * frac
// }

View File

@ -1,70 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { ClickEvent } from '../../common/tracking'
import { getWordScores } from '../../common/recommended-contracts'
import { batchedWaitAll } from '../../common/util/promise'
import { callCloudFunction } from './call-cloud-function'
const firestore = admin.firestore()
export const updateRecommendations = functions.pubsub
.schedule('every 24 hours')
.onRun(async () => {
const users = await getValues<User>(firestore.collection('users'))
const batchSize = 100
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += batchSize) {
userBatches.push(users.slice(i, i + batchSize))
}
await Promise.all(
userBatches.map((batch) =>
callCloudFunction('updateRecommendationsBatch', { users: batch })
)
)
})
export const updateRecommendationsBatch = functions.https.onCall(
async (data: { users: User[] }) => {
const { users } = data
const contracts = await getValues<Contract>(
firestore.collection('contracts')
)
await batchedWaitAll(
users.map((user) => () => updateWordScores(user, contracts))
)
}
)
export const updateWordScores = async (user: User, contracts: Contract[]) => {
const [bets, viewCounts, clicks] = await Promise.all([
getValues<Bet>(
firestore.collectionGroup('bets').where('userId', '==', user.id)
),
getValue<{ [contractId: string]: number }>(
firestore.doc(`private-users/${user.id}/cache/viewCounts`)
),
getValues<ClickEvent>(
firestore
.collection(`private-users/${user.id}/events`)
.where('type', '==', 'click')
),
])
const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets)
const cachedCollection = firestore.collection(
`private-users/${user.id}/cache`
)
await cachedCollection.doc('wordScores').set(wordScores)
}

View File

@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react'
import { partition, sumBy } from 'lodash' import { partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { BinaryContract, CPMMBinaryContract } from 'common/contract' import {
BinaryContract,
CPMMBinaryContract,
PseudoNumericContract,
} from 'common/contract'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { sellShares } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call'
import { AmountInput, BuyAmountInput } from './amount-input' import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label' import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
import { import {
calculatePayoutAfterCorrectBet, calculatePayoutAfterCorrectBet,
calculateShares, calculateShares,
@ -35,6 +39,7 @@ import {
getCpmmProbability, getCpmmProbability,
getCpmmLiquidityFee, getCpmmLiquidityFee,
} from 'common/calculate-cpmm' } from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useSaveShares } from './use-save-shares' import { useSaveShares } from './use-save-shares'
import { SignUpPrompt } from './sign-up-prompt' import { SignUpPrompt } from './sign-up-prompt'
@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
export function BetPanel(props: { export function BetPanel(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
className?: string className?: string
}) { }) {
const { contract, className } = props const { contract, className } = props
@ -81,7 +86,7 @@ export function BetPanel(props: {
} }
export function BetPanelSwitcher(props: { export function BetPanelSwitcher(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
className?: string className?: string
title?: string // Set if BetPanel is on a feed modal title?: string // Set if BetPanel is on a feed modal
selected?: 'YES' | 'NO' selected?: 'YES' | 'NO'
@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: {
}) { }) {
const { contract, className, title, selected, onBetSuccess } = props const { contract, className, title, selected, onBetSuccess } = props
const { mechanism } = contract const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: {
<Row className="items-center justify-between gap-2"> <Row className="items-center justify-between gap-2">
<div> <div>
You have {formatWithCommas(floorShares)}{' '} You have {formatWithCommas(floorShares)}{' '}
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares {isPseudoNumeric ? (
<PseudoNumericOutcomeLabel outcome={sharesOutcome} />
) : (
<BinaryOutcomeLabel outcome={sharesOutcome} />
)}{' '}
shares
</div> </div>
{tradeType === 'BUY' && ( {tradeType === 'BUY' && (
@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: {
} }
function BuyPanel(props: { function BuyPanel(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
selected?: 'YES' | 'NO' selected?: 'YES' | 'NO'
onBuySuccess?: () => void onBuySuccess?: () => void
}) { }) {
const { contract, user, selected, onBuySuccess } = props const { contract, user, selected, onBuySuccess } = props
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
@ -302,6 +314,9 @@ function BuyPanel(props: {
: 0) : 0)
)} ${betChoice ?? 'YES'} shares` )} ${betChoice ?? 'YES'} shares`
: undefined : undefined
const format = getFormattedMappedValue(contract)
return ( return (
<> <>
<YesNoSelector <YesNoSelector
@ -309,6 +324,7 @@ function BuyPanel(props: {
btnClassName="flex-1" btnClassName="flex-1"
selected={betChoice} selected={betChoice}
onSelect={(choice) => onBetChoice(choice)} onSelect={(choice) => onBetChoice(choice)}
isPseudoNumeric={isPseudoNumeric}
/> />
<div className="my-3 text-left text-sm text-gray-500">Amount</div> <div className="my-3 text-left text-sm text-gray-500">Amount</div>
<BuyAmountInput <BuyAmountInput
@ -323,11 +339,13 @@ function BuyPanel(props: {
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div> <div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
<div> <div>
{formatPercent(initialProb)} {format(initialProb)}
<span className="mx-2"></span> <span className="mx-2"></span>
{formatPercent(resultProb)} {format(resultProb)}
</div> </div>
</Row> </Row>
@ -340,6 +358,8 @@ function BuyPanel(props: {
<br /> payout if{' '} <br /> payout if{' '}
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
</> </>
) : isPseudoNumeric ? (
'Max payout'
) : ( ) : (
<> <>
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
@ -389,7 +409,7 @@ function BuyPanel(props: {
} }
export function SellPanel(props: { export function SellPanel(props: {
contract: CPMMBinaryContract contract: CPMMBinaryContract | PseudoNumericContract
userBets: Bet[] userBets: Bet[]
shares: number shares: number
sharesOutcome: 'YES' | 'NO' sharesOutcome: 'YES' | 'NO'
@ -488,6 +508,10 @@ export function SellPanel(props: {
} }
} }
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const format = getFormattedMappedValue(contract)
return ( return (
<> <>
<AmountInput <AmountInput
@ -511,11 +535,13 @@ export function SellPanel(props: {
<span className="text-neutral">{formatMoney(saleValue)}</span> <span className="text-neutral">{formatMoney(saleValue)}</span>
</Row> </Row>
<Row className="items-center justify-between"> <Row className="items-center justify-between">
<div className="text-gray-500">Probability</div> <div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
<div> <div>
{formatPercent(initialProb)} {format(initialProb)}
<span className="mx-2"></span> <span className="mx-2"></span>
{formatPercent(resultProb)} {format(resultProb)}
</div> </div>
</Row> </Row>
</Col> </Col>

View File

@ -3,7 +3,7 @@ import clsx from 'clsx'
import { BetPanelSwitcher } from './bet-panel' import { BetPanelSwitcher } from './bet-panel'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { BinaryContract } from 'common/contract' import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { SellButton } from './sell-button' import { SellButton } from './sell-button'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares'
// Inline version of a bet panel. Opens BetPanel in a new modal. // Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: { export default function BetRow(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
className?: string className?: string
btnClassName?: string btnClassName?: string
betPanelClassName?: string betPanelClassName?: string
@ -32,6 +32,7 @@ export default function BetRow(props: {
return ( return (
<> <>
<YesNoSelector <YesNoSelector
isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'}
className={clsx('justify-end', className)} className={clsx('justify-end', className)}
btnClassName={clsx('btn-sm w-24', btnClassName)} btnClassName={clsx('btn-sm w-24', btnClassName)}
onSelect={(choice) => { onSelect={(choice) => {

View File

@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets'
import { Bet } from 'web/lib/firebase/bets' import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { import {
formatLargeNumber,
formatMoney, formatMoney,
formatPercent, formatPercent,
formatWithCommas, formatWithCommas,
@ -40,6 +41,7 @@ import {
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking' import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract' import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
@ -366,6 +368,7 @@ export function BetsSummary(props: {
const { contract, isYourBets, className } = props const { contract, isYourBets, className } = props
const { resolution, closeTime, outcomeType, mechanism } = contract const { resolution, closeTime, outcomeType, mechanism } = contract
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isCpmm = mechanism === 'cpmm-1' const isCpmm = mechanism === 'cpmm-1'
const isClosed = closeTime && Date.now() > closeTime const isClosed = closeTime && Date.now() > closeTime
@ -427,6 +430,25 @@ export function BetsSummary(props: {
</div> </div>
</Col> </Col>
</> </>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">
{formatMoney(noWinnings)}
</div>
</Col>
</>
) : ( ) : (
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
@ -507,13 +529,15 @@ export function ContractBetsTable(props: {
const { isResolved, mechanism, outcomeType } = contract const { isResolved, mechanism, outcomeType } = contract
const isCPMM = mechanism === 'cpmm-1' const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
return ( return (
<div className={clsx('overflow-x-auto', className)}> <div className={clsx('overflow-x-auto', className)}>
{amountRedeemed > 0 && ( {amountRedeemed > 0 && (
<> <>
<div className="pl-2 text-sm text-gray-500"> <div className="pl-2 text-sm text-gray-500">
{amountRedeemed} YES shares and {amountRedeemed} NO shares {amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '}
{amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares
automatically redeemed for {formatMoney(amountRedeemed)}. automatically redeemed for {formatMoney(amountRedeemed)}.
</div> </div>
<Spacer h={4} /> <Spacer h={4} />
@ -541,7 +565,7 @@ export function ContractBetsTable(props: {
)} )}
{!isCPMM && !isResolved && <th>Payout if chosen</th>} {!isCPMM && !isResolved && <th>Payout if chosen</th>}
<th>Shares</th> <th>Shares</th>
<th>Probability</th> {!isPseudoNumeric && <th>Probability</th>}
<th>Date</th> <th>Date</th>
</tr> </tr>
</thead> </thead>
@ -585,6 +609,7 @@ function BetRow(props: {
const isCPMM = mechanism === 'cpmm-1' const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const saleAmount = saleBet?.sale?.amount const saleAmount = saleBet?.sale?.amount
@ -628,14 +653,18 @@ function BetRow(props: {
truncate="short" truncate="short"
/> />
)} )}
{isPseudoNumeric &&
' than ' + formatNumericProbability(bet.probAfter, contract)}
</td> </td>
<td>{formatMoney(Math.abs(amount))}</td> <td>{formatMoney(Math.abs(amount))}</td>
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>} {!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>} {!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
<td>{formatWithCommas(Math.abs(shares))}</td> <td>{formatWithCommas(Math.abs(shares))}</td>
<td> {!isPseudoNumeric && (
{formatPercent(probBefore)} {formatPercent(probAfter)} <td>
</td> {formatPercent(probBefore)} {formatPercent(probAfter)}
</td>
)}
<td>{dayjs(createdTime).format('MMM D, h:mma')}</td> <td>{dayjs(createdTime).format('MMM D, h:mma')}</td>
</tr> </tr>
) )

View File

@ -9,7 +9,7 @@ import {
useSortBy, useSortBy,
} from 'react-instantsearch-hooks-web' } from 'react-instantsearch-hooks-web'
import { Contract } from '../../common/contract' import { Contract } from 'common/contract'
import { import {
Sort, Sort,
useInitialQueryAndSort, useInitialQueryAndSort,
@ -58,15 +58,24 @@ export function ContractSearch(props: {
additionalFilter?: { additionalFilter?: {
creatorId?: string creatorId?: string
tag?: string tag?: string
excludeContractIds?: string[]
} }
showCategorySelector: boolean showCategorySelector: boolean
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
showPlaceHolder?: boolean
hideOrderSelector?: boolean
overrideGridClassName?: string
hideQuickBet?: boolean
}) { }) {
const { const {
querySortOptions, querySortOptions,
additionalFilter, additionalFilter,
showCategorySelector, showCategorySelector,
onContractClick, onContractClick,
overrideGridClassName,
hideOrderSelector,
showPlaceHolder,
hideQuickBet,
} = props } = props
const user = useUser() const user = useUser()
@ -136,6 +145,7 @@ export function ContractSearch(props: {
<Row className="gap-1 sm:gap-2"> <Row className="gap-1 sm:gap-2">
<SearchBox <SearchBox
className="flex-1" className="flex-1"
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
classNames={{ classNames={{
form: 'before:top-6', form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none w-[100px]', input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
@ -153,13 +163,15 @@ export function ContractSearch(props: {
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
<option value="all">All</option> <option value="all">All</option>
</select> </select>
<SortBy {!hideOrderSelector && (
items={sortIndexes} <SortBy
classNames={{ items={sortIndexes}
select: '!select !select-bordered', classNames={{
}} select: '!select !select-bordered',
onBlur={trackCallback('select search sort')} }}
/> onBlur={trackCallback('select search sort')}
/>
)}
<Configure <Configure
facetFilters={filters} facetFilters={filters}
numericFilters={numericFilters} numericFilters={numericFilters}
@ -187,6 +199,9 @@ export function ContractSearch(props: {
<ContractSearchInner <ContractSearchInner
querySortOptions={querySortOptions} querySortOptions={querySortOptions}
onContractClick={onContractClick} onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
excludeContractIds={additionalFilter?.excludeContractIds}
/> />
)} )}
</InstantSearch> </InstantSearch>
@ -199,8 +214,17 @@ export function ContractSearchInner(props: {
shouldLoadFromStorage?: boolean shouldLoadFromStorage?: boolean
} }
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
excludeContractIds?: string[]
}) { }) {
const { querySortOptions, onContractClick } = props const {
querySortOptions,
onContractClick,
overrideGridClassName,
hideQuickBet,
excludeContractIds,
} = props
const { initialQuery } = useInitialQueryAndSort(querySortOptions) const { initialQuery } = useInitialQueryAndSort(querySortOptions)
const { query, setQuery, setSort } = useUpdateQueryAndSort({ const { query, setQuery, setSort } = useUpdateQueryAndSort({
@ -239,7 +263,7 @@ export function ContractSearchInner(props: {
}, []) }, [])
const { showMore, hits, isLastPage } = useInfiniteHits() const { showMore, hits, isLastPage } = useInfiniteHits()
const contracts = hits as any as Contract[] let contracts = hits as any as Contract[]
if (isInitialLoad && contracts.length === 0) return <></> if (isInitialLoad && contracts.length === 0) return <></>
@ -249,6 +273,9 @@ export function ContractSearchInner(props: {
? 'resolve-date' ? 'resolve-date'
: undefined : undefined
if (excludeContractIds)
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
return ( return (
<ContractsGrid <ContractsGrid
contracts={contracts} contracts={contracts}
@ -256,6 +283,8 @@ export function ContractSearchInner(props: {
hasMore={!isLastPage} hasMore={!isLastPage}
showTime={showTime} showTime={showTime}
onContractClick={onContractClick} onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
/> />
) )
} }

View File

@ -9,6 +9,7 @@ import {
BinaryContract, BinaryContract,
FreeResponseContract, FreeResponseContract,
NumericContract, NumericContract,
PseudoNumericContract,
} from 'common/contract' } from 'common/contract'
import { import {
AnswerLabel, AnswerLabel,
@ -16,7 +17,11 @@ import {
CancelLabel, CancelLabel,
FreeResponseOutcomeLabel, FreeResponseOutcomeLabel,
} from '../outcome-label' } from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate' import {
getOutcomeProbability,
getProbability,
getTopAnswer,
} from 'common/calculate'
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet' import { QuickBet, ProbBar, getColor } from './quick-bet'
@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -131,6 +137,13 @@ export function ContractCard(props: {
/> />
)} )}
{outcomeType === 'PSEUDO_NUMERIC' && (
<PseudoNumericResolutionOrExpectation
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation <NumericResolutionOrExpectation
className="items-center" className="items-center"
@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? ( {resolution === 'CANCEL' ? (
<CancelLabel /> <CancelLabel />
) : ( ) : (
<div className="text-blue-400">{resolutionValue}</div> <div className="text-blue-400">
{formatLargeNumber(resolutionValue)}
</div>
)} )}
</> </>
) : ( ) : (
@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: {
</Col> </Col>
) )
} }
export function PseudoNumericResolutionOrExpectation(props: {
contract: PseudoNumericContract
className?: string
}) {
const { contract, className } = props
const { resolution, resolutionValue, resolutionProbability } = contract
const textColor = `text-blue-400`
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">
{resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? 0,
contract
)}
</div>
)}
</>
) : (
<>
<div className={clsx('text-3xl', textColor)}>
{formatNumericProbability(getProbability(contract), contract)}
</div>
<div className={clsx('text-base', textColor)}>expected</div>
</>
)}
</Col>
)
}

View File

@ -130,9 +130,32 @@ export function ContractDetails(props: {
const { contract, bets, isCreator, disabled } = props const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId } = contract const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract) const { volumeLabel, resolvedDate } = contractMetrics(contract)
// Find a group that this contract id is in
const groups = useGroupsWithContract(contract.id) const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => {
return g2.createdTime - g1.createdTime
})
const user = useUser() const user = useUser()
const groupsUserIsMemberOf = groups
? groups.filter((g) => g.memberIds.includes(contract.creatorId))
: []
const groupsUserIsCreatorOf = groups
? groups.filter((g) => g.creatorId === contract.creatorId)
: []
// Priorities for which group the contract belongs to:
// In order of created most recently
// Group that the contract owner created
// Group the contract owner is a member of
// Any group the contract is in
const groupToDisplay =
groupsUserIsCreatorOf.length > 0
? groupsUserIsCreatorOf[0]
: groupsUserIsMemberOf.length > 0
? groupsUserIsMemberOf[0]
: groups
? groups[0]
: undefined
return ( return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2"> <Row className="items-center gap-2">
@ -153,14 +176,15 @@ export function ContractDetails(props: {
)} )}
{!disabled && <UserFollowButton userId={creatorId} small />} {!disabled && <UserFollowButton userId={creatorId} small />}
</Row> </Row>
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/} {groupToDisplay ? (
{groups && groups.length > 0 && (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}> <Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groups[0].slug)}`}> <SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" /> <UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groups[0].name}</span> <span>{groupToDisplay.name}</span>
</SiteLink> </SiteLink>
</Row> </Row>
) : (
<div />
)} )}
{(!!closeTime || !!resolvedDate) && ( {(!!closeTime || !!resolvedDate) && (

View File

@ -11,6 +11,7 @@ import {
FreeResponseResolutionOrChance, FreeResponseResolutionOrChance,
BinaryResolutionOrChance, BinaryResolutionOrChance,
NumericResolutionOrExpectation, NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation,
} from './contract-card' } from './contract-card'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetRow from '../bet-row' import BetRow from '../bet-row'
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
const user = useUser() const user = useUser()
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
return ( return (
<Col className={clsx('mb-6', className)}> <Col className={clsx('mb-6', className)}>
@ -49,6 +51,13 @@ export const ContractOverview = (props: {
/> />
)} )}
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
)}
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation <NumericResolutionOrExpectation
contract={contract} contract={contract}
@ -61,6 +70,11 @@ export const ContractOverview = (props: {
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />} {tradingAllowed(contract) && <BetRow contract={contract} />}
</Row> </Row>
) : ( ) : (
@ -86,7 +100,9 @@ export const ContractOverview = (props: {
/> />
</Col> </Col>
<Spacer h={4} /> <Spacer h={4} />
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '} {(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} />
)}{' '}
{outcomeType === 'FREE_RESPONSE' && ( {outcomeType === 'FREE_RESPONSE' && (
<AnswersGraph contract={contract} bets={bets} /> <AnswersGraph contract={contract} bets={bets} />
)} )}

View File

@ -5,16 +5,20 @@ import dayjs from 'dayjs'
import { memo } from 'react' import { memo } from 'react'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate' import { getInitialProbability } from 'common/calculate'
import { BinaryContract } from 'common/contract' import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { getMappedValue } from 'common/pseudo-numeric'
import { formatLargeNumber } from 'common/util/format'
export const ContractProbGraph = memo(function ContractProbGraph(props: { export const ContractProbGraph = memo(function ContractProbGraph(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
bets: Bet[] bets: Bet[]
height?: number height?: number
}) { }) {
const { contract, height } = props const { contract, height } = props
const { resolutionTime, closeTime } = contract const { resolutionTime, closeTime, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
contract.createdTime, contract.createdTime,
...bets.map((bet) => bet.createdTime), ...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time)) ].map((time) => new Date(time))
const probs = [startProb, ...bets.map((bet) => bet.probAfter)]
const f = getMappedValue(contract)
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
const isClosed = !!closeTime && Date.now() > closeTime const isClosed = !!closeTime && Date.now() > closeTime
const latestTime = dayjs( const latestTime = dayjs(
@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
times.push(latestTime.toDate()) times.push(latestTime.toDate())
probs.push(probs[probs.length - 1]) probs.push(probs[probs.length - 1])
const yTickValues = [0, 25, 50, 75, 100] const quartiles = [0, 25, 50, 75, 100]
const yTickValues = isBinary
? quartiles
: quartiles.map((x) => x / 100).map(f)
const { width } = useWindowSize() const { width } = useWindowSize()
@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const totalPoints = width ? (width > 800 ? 300 : 50) : 1
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
const points: { x: Date; y: number }[] = [] const points: { x: Date; y: number }[] = []
const s = isBinary ? 100 : 1
const c = isLogScale && contract.min === 0 ? 1 : 0
for (let i = 0; i < times.length - 1; i++) { for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: probs[i] * 100 } points[points.length] = { x: times[i], y: s * probs[i] + c }
const numPoints: number = Math.floor( const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
) )
@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
x: dayjs(times[i]) x: dayjs(times[i])
.add(thisTimeStep * n, 'ms') .add(thisTimeStep * n, 'ms')
.toDate(), .toDate(),
y: probs[i] * 100, y: s * probs[i] + c,
} }
} }
} }
} }
const data = [{ id: 'Yes', data: points, color: '#11b981' }] const data = [
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
]
const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
const formatter = isBinary
? formatPercent
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
return ( return (
<div <div
className="w-full overflow-visible" className="w-full overflow-visible"
@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
> >
<ResponsiveLine <ResponsiveLine
data={data} data={data}
yScale={{ min: 0, max: 100, type: 'linear' }} yScale={
yFormat={formatPercent} isBinary
? { min: 0, max: 100, type: 'linear' }
: {
min: contract.min + c,
max: contract.max + c,
type: contract.isLogScale ? 'log' : 'linear',
}
}
yFormat={formatter}
gridYValues={yTickValues} gridYValues={yTickValues}
axisLeft={{ axisLeft={{
tickValues: yTickValues, tickValues: yTickValues,
format: formatPercent, format: formatter,
}} }}
xScale={{ xScale={{
type: 'time', type: 'time',

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { import {
getOutcomeProbability, getOutcomeProbability,
getOutcomeProbabilityAfterBet, getOutcomeProbabilityAfterBet,
getProbability,
getTopAnswer, getTopAnswer,
} from 'common/calculate' } from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm' import { getExpectedValue } from 'common/calculate-dpm'
@ -25,18 +26,18 @@ import { useSaveShares } from '../use-save-shares'
import { sellShares } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
const BET_SIZE = 10 const BET_SIZE = 10
export function QuickBet(props: { contract: Contract; user: User }) { export function QuickBet(props: { contract: Contract; user: User }) {
const { contract, user } = props const { contract, user } = props
const isCpmm = contract.mechanism === 'cpmm-1' const { mechanism, outcomeType } = contract
const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id) const userBets = useUserContractBets(user.id, contract.id)
const topAnswer = const topAnswer =
contract.outcomeType === 'FREE_RESPONSE' outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
? getTopAnswer(contract)
: undefined
// TODO: yes/no from useSaveShares doesn't work on numeric contracts // TODO: yes/no from useSaveShares doesn't work on numeric contracts
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) {
topAnswer?.number.toString() || undefined topAnswer?.number.toString() || undefined
) )
const hasUpShares = const hasUpShares =
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC') yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC')
const hasDownShares = const hasDownShares =
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC' noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC'
const [upHover, setUpHover] = useState(false) const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = useState(false) const [downHover, setDownHover] = useState(false)
@ -130,25 +131,6 @@ export function QuickBet(props: { contract: Contract; user: User }) {
}) })
} }
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
if (contract.outcomeType === 'BINARY') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (contract.outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (contract.outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
const textColor = `text-${getColor(contract)}`
return ( return (
<Col <Col
className={clsx( className={clsx(
@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) {
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? textColor : 'text-gray-400' upHover ? 'text-green-500' : 'text-gray-400'
)} )}
/> />
) : ( ) : (
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? textColor : 'text-gray-200' upHover ? 'text-green-500' : 'text-gray-200'
)} )}
/> />
)} )}
@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) {
<QuickOutcomeView contract={contract} previewProb={previewProb} /> <QuickOutcomeView contract={contract} previewProb={previewProb} />
{/* Down bet triangle */} {/* Down bet triangle */}
{contract.outcomeType !== 'BINARY' ? ( {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
<div> <div>
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div> <div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
<TriangleDownFillIcon <TriangleDownFillIcon
@ -254,6 +236,25 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) {
) )
} }
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
const { outcomeType } = contract
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
function QuickOutcomeView(props: { function QuickOutcomeView(props: {
contract: Contract contract: Contract
previewProb?: number previewProb?: number
@ -261,9 +262,16 @@ function QuickOutcomeView(props: {
}) { }) {
const { contract, previewProb, caption } = props const { contract, previewProb, caption } = props
const { outcomeType } = contract const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
// If there's a preview prob, display that instead of the current prob // If there's a preview prob, display that instead of the current prob
const override = const override =
previewProb === undefined ? undefined : formatPercent(previewProb) previewProb === undefined
? undefined
: isPseudoNumeric
? formatNumericProbability(previewProb, contract)
: formatPercent(previewProb)
const textColor = `text-${getColor(contract)}` const textColor = `text-${getColor(contract)}`
let display: string | undefined let display: string | undefined
@ -271,6 +279,9 @@ function QuickOutcomeView(props: {
case 'BINARY': case 'BINARY':
display = getBinaryProbPercent(contract) display = getBinaryProbPercent(contract)
break break
case 'PSEUDO_NUMERIC':
display = formatNumericProbability(getProbability(contract), contract)
break
case 'NUMERIC': case 'NUMERIC':
display = formatLargeNumber(getExpectedValue(contract)) display = formatLargeNumber(getExpectedValue(contract))
break break
@ -295,11 +306,15 @@ function QuickOutcomeView(props: {
// Return a number from 0 to 1 for this contract // Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO) // Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) { function getProb(contract: Contract) {
const { outcomeType, resolution } = contract const { outcomeType, resolution, resolutionProbability } = contract
return resolution return resolutionProbability
? resolutionProbability
: resolution
? 1 ? 1
: outcomeType === 'BINARY' : outcomeType === 'BINARY'
? getBinaryProb(contract) ? getBinaryProb(contract)
: outcomeType === 'PSEUDO_NUMERIC'
? getProbability(contract)
: outcomeType === 'FREE_RESPONSE' : outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC' : outcomeType === 'NUMERIC'
@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) {
export function getColor(contract: Contract) { export function getColor(contract: Contract) {
// TODO: Try injecting a gradient here // TODO: Try injecting a gradient here
// return 'primary' // return 'primary'
const { resolution } = contract const { resolution, outcomeType } = contract
if (resolution) { if (resolution) {
return ( return (
OUTCOME_TO_COLOR[resolution as resolution] ?? OUTCOME_TO_COLOR[resolution as resolution] ??
@ -325,6 +341,8 @@ export function getColor(contract: Contract) {
) )
} }
if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400'
if ((contract.closeTime ?? Infinity) < Date.now()) { if ((contract.closeTime ?? Infinity) < Date.now()) {
return 'gray-400' return 'gray-400'
} }

View File

@ -7,13 +7,14 @@ import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx' import clsx from 'clsx'
import { UsersIcon } from '@heroicons/react/solid' import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { uniqBy, partition, sumBy, groupBy } from 'lodash'
import { JoinSpans } from 'web/components/join-spans' import { JoinSpans } from 'web/components/join-spans'
import { UserLink } from '../user-page' import { UserLink } from '../user-page'
import { formatNumericProbability } from 'common/pseudo-numeric'
export function FeedBet(props: { export function FeedBet(props: {
contract: Contract contract: Contract
@ -75,6 +76,8 @@ export function BetStatusText(props: {
hideOutcome?: boolean hideOutcome?: boolean
}) { }) {
const { bet, contract, bettor, isSelf, hideOutcome } = props const { bet, contract, bettor, isSelf, hideOutcome } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const { amount, outcome, createdTime } = bet const { amount, outcome, createdTime } = bet
const bought = amount >= 0 ? 'bought' : 'sold' const bought = amount >= 0 ? 'bought' : 'sold'
@ -97,7 +100,10 @@ export function BetStatusText(props: {
value={(bet as any).value} value={(bet as any).value}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />{' '}
{isPseudoNumeric
? ' than ' + formatNumericProbability(bet.probAfter, contract)
: ' at ' + formatPercent(bet.probAfter)}
</> </>
)} )}
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />

View File

@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users' import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user' import { User } from 'common/user'
import { uniq } from 'lodash'
export function EditGroupButton(props: { group: Group; className?: string }) { export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props const { group, className } = props
@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
await updateGroup(group, { await updateGroup(group, {
name, name,
about, about,
memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)], memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
}) })
setIsSubmitting(false) setIsSubmitting(false)

View File

@ -9,7 +9,7 @@ import { TextButton } from 'web/components/text-button'
import { Group } from 'common/group' import { Group } from 'common/group'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { GroupLink } from 'web/pages/groups' import { GroupLink } from 'web/pages/groups'
@ -93,7 +93,7 @@ export function JoinOrLeaveGroupButton(props: {
: false : false
const onJoinGroup = () => { const onJoinGroup = () => {
if (!currentUser) return if (!currentUser) return
joinGroup(group, currentUser.id) addUserToGroup(group, currentUser.id)
} }
const onLeaveGroup = () => { const onLeaveGroup = () => {
if (!currentUser) return if (!currentUser) return

View File

@ -1,13 +1,15 @@
import { Fragment, ReactNode } from 'react' import { Fragment, ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import clsx from 'clsx'
// From https://tailwindui.com/components/application-ui/overlays/modals // From https://tailwindui.com/components/application-ui/overlays/modals
export function Modal(props: { export function Modal(props: {
children: ReactNode children: ReactNode
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
className?: string
}) { }) {
const { children, open, setOpen } = props const { children, open, setOpen, className } = props
return ( return (
<Transition.Root show={open} as={Fragment}> <Transition.Root show={open} as={Fragment}>
@ -45,7 +47,12 @@ export function Modal(props: {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<div className="inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle"> <div
className={clsx(
'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle',
className
)}
>
{children} {children}
</div> </div>
</Transition.Child> </Transition.Child>

View File

@ -14,16 +14,16 @@ type Tab = {
export function Tabs(props: { export function Tabs(props: {
tabs: Tab[] tabs: Tab[]
defaultIndex?: number defaultIndex?: number
className?: string labelClassName?: string
onClick?: (tabTitle: string, index: number) => void onClick?: (tabTitle: string, index: number) => void
}) { }) {
const { tabs, defaultIndex, className, onClick } = props const { tabs, defaultIndex, labelClassName, onClick } = props
const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0) const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
return ( return (
<div> <>
<div className="border-b border-gray-200"> <div className="mb-4 border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs"> <nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
@ -42,7 +42,7 @@ export function Tabs(props: {
? 'border-indigo-500 text-indigo-600' ? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700', : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium', 'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium',
className labelClassName
)} )}
aria-current={activeIndex === i ? 'page' : undefined} aria-current={activeIndex === i ? 'page' : undefined}
> >
@ -56,7 +56,7 @@ export function Tabs(props: {
</nav> </nav>
</div> </div>
<div className="mt-4">{activeTab?.content}</div> {activeTab?.content}
</div> </>
) )
} }

View File

@ -254,7 +254,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
<div className="mt-1 space-y-0.5"> <div className="mt-1 space-y-0.5">
{memberItems.map((item) => ( {memberItems.map((item) => (
<a <a
key={item.name} key={item.href}
href={item.href} href={item.href}
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900" className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
> >

View File

@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users'
import { NumberCancelSelector } from './yes-no-selector' import { NumberCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button' import { ResolveConfirmationButton } from './confirmation-button'
import { NumericContract, PseudoNumericContract } from 'common/contract'
import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
import { NumericContract } from 'common/contract'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
import { getPseudoProbability } from 'common/pseudo-numeric'
export function NumericResolutionPanel(props: { export function NumericResolutionPanel(props: {
creator: User creator: User
contract: NumericContract contract: NumericContract | PseudoNumericContract
className?: string className?: string
}) { }) {
useEffect(() => { useEffect(() => {
@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: {
}, []) }, [])
const { contract, className } = props const { contract, className } = props
const { min, max, outcomeType } = contract
const [outcomeMode, setOutcomeMode] = useState< const [outcomeMode, setOutcomeMode] = useState<
'NUMBER' | 'CANCEL' | undefined 'NUMBER' | 'CANCEL' | undefined
@ -32,15 +34,32 @@ export function NumericResolutionPanel(props: {
const [error, setError] = useState<string | undefined>(undefined) const [error, setError] = useState<string | undefined>(undefined)
const resolve = async () => { const resolve = async () => {
const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL' const finalOutcome =
outcomeMode === 'CANCEL'
? 'CANCEL'
: outcomeType === 'PSEUDO_NUMERIC'
? 'MKT'
: 'NUMBER'
if (outcomeMode === undefined || finalOutcome === undefined) return if (outcomeMode === undefined || finalOutcome === undefined) return
setIsSubmitting(true) setIsSubmitting(true)
const boundedValue = Math.max(Math.min(max, value ?? 0), min)
const probabilityInt =
100 *
getPseudoProbability(
boundedValue,
min,
max,
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
)
try { try {
const result = await resolveMarket({ const result = await resolveMarket({
outcome: finalOutcome, outcome: finalOutcome,
value, value,
probabilityInt,
contractId: contract.id, contractId: contract.id,
}) })
console.log('resolved', outcome, 'result:', result) console.log('resolved', outcome, 'result:', result)
@ -77,7 +96,7 @@ export function NumericResolutionPanel(props: {
{outcomeMode === 'NUMBER' && ( {outcomeMode === 'NUMBER' && (
<BucketInput <BucketInput
contract={contract} contract={contract as any}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onBucketChange={(v, o) => (setValue(v), setOutcome(o))} onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
/> />

View File

@ -19,11 +19,15 @@ export function OutcomeLabel(props: {
value?: number value?: number
}) { }) {
const { outcome, contract, truncate, value } = props const { outcome, contract, truncate, value } = props
const { outcomeType } = contract
if (contract.outcomeType === 'BINARY') if (outcomeType === 'PSEUDO_NUMERIC')
return <PseudoNumericOutcomeLabel outcome={outcome as any} />
if (outcomeType === 'BINARY')
return <BinaryOutcomeLabel outcome={outcome as any} /> return <BinaryOutcomeLabel outcome={outcome as any} />
if (contract.outcomeType === 'NUMERIC') if (outcomeType === 'NUMERIC')
return ( return (
<span className="text-blue-500"> <span className="text-blue-500">
{value ?? getValueFromBucket(outcome, contract)} {value ?? getValueFromBucket(outcome, contract)}
@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) {
return <CancelLabel /> return <CancelLabel />
} }
export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) {
const { outcome } = props
if (outcome === 'YES') return <HigherLabel />
if (outcome === 'NO') return <LowerLabel />
if (outcome === 'MKT') return <ProbLabel />
return <CancelLabel />
}
export function BinaryContractOutcomeLabel(props: { export function BinaryContractOutcomeLabel(props: {
contract: BinaryContract contract: BinaryContract
resolution: resolution resolution: resolution
@ -98,6 +111,14 @@ export function YesLabel() {
return <span className="text-primary">YES</span> return <span className="text-primary">YES</span>
} }
export function HigherLabel() {
return <span className="text-primary">HIGHER</span>
}
export function LowerLabel() {
return <span className="text-red-400">LOWER</span>
}
export function NoLabel() { export function NoLabel() {
return <span className="text-red-400">NO</span> return <span className="text-red-400">NO</span>
} }

View File

@ -52,7 +52,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
margin={{ top: 20, right: 28, bottom: 22, left: 60 }} margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
xScale={{ xScale={{
type: 'time', type: 'time',
min: points[0].x, min: points[0]?.x,
max: endDate, max: endDate,
}} }}
yScale={{ yScale={{
@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
enableGridY={true} enableGridY={true}
enableSlices="x" enableSlices="x"
animate={false} animate={false}
yFormat={(value) => formatMoney(+value)}
></ResponsiveLine> ></ResponsiveLine>
</div> </div>
) )

View File

@ -13,7 +13,7 @@ export const PortfolioValueSection = memo(
}) { }) {
const { portfolioHistory } = props const { portfolioHistory } = props
const lastPortfolioMetrics = last(portfolioHistory) const lastPortfolioMetrics = last(portfolioHistory)
const [portfolioPeriod] = useState<Period>('allTime') const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
return <div> No portfolio history data yet </div> return <div> No portfolio history data yet </div>
@ -33,9 +33,16 @@ export const PortfolioValueSection = memo(
</div> </div>
</Col> </Col>
</div> </div>
{ <select
//TODO: enable day/week/monthly as data becomes available className="select select-bordered self-start"
} onChange={(e) => {
setPortfolioPeriod(e.target.value as Period)
}}
>
<option value="allTime">All time</option>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</Row> </Row>
<PortfolioValueGraph <PortfolioValueGraph
portfolioHistory={portfolioHistory} portfolioHistory={portfolioHistory}

View File

@ -1,4 +1,4 @@
import { BinaryContract } from 'common/contract' import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useState } from 'react' import { useState } from 'react'
@ -7,7 +7,7 @@ import clsx from 'clsx'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
export function SellButton(props: { export function SellButton(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
sharesOutcome: 'YES' | 'NO' | undefined sharesOutcome: 'YES' | 'NO' | undefined
shares: number shares: number
@ -16,7 +16,8 @@ export function SellButton(props: {
const { contract, user, sharesOutcome, shares, panelClassName } = props const { contract, user, sharesOutcome, shares, panelClassName } = props
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false) const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
if (sharesOutcome && user && mechanism === 'cpmm-1') { if (sharesOutcome && user && mechanism === 'cpmm-1') {
return ( return (
@ -32,7 +33,10 @@ export function SellButton(props: {
)} )}
onClick={() => setShowSellModal(true)} onClick={() => setShowSellModal(true)}
> >
{'Sell ' + sharesOutcome} Sell{' '}
{isPseudoNumeric
? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome]
: sharesOutcome}
</button> </button>
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{'(' + Math.floor(shares) + ' shares)'} {'(' + Math.floor(shares) + ' shares)'}

View File

@ -1,4 +1,4 @@
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { User } from 'common/user' import { User } from 'common/user'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
@ -11,7 +11,7 @@ import clsx from 'clsx'
export function SellSharesModal(props: { export function SellSharesModal(props: {
className?: string className?: string
contract: CPMMBinaryContract contract: CPMMBinaryContract | PseudoNumericContract
userBets: Bet[] userBets: Bet[]
shares: number shares: number
sharesOutcome: 'YES' | 'NO' sharesOutcome: 'YES' | 'NO'

View File

@ -1,4 +1,4 @@
import { BinaryContract } from 'common/contract' import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useState } from 'react' import { useState } from 'react'
import { Col } from './layout/col' import { Col } from './layout/col'
@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
export function SellRow(props: { export function SellRow(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
className?: string className?: string
}) { }) {

View File

@ -38,6 +38,7 @@ import { FollowButton } from './follow-button'
import { PortfolioMetrics } from 'common/user' import { PortfolioMetrics } from 'common/user'
import { ReferralsButton } from 'web/components/referrals-button' import { ReferralsButton } from 'web/components/referrals-button'
import { GroupsButton } from 'web/components/groups/groups-button' import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
export function UserLink(props: { export function UserLink(props: {
name: string name: string
@ -75,7 +76,9 @@ export function UserPage(props: {
'loading' 'loading'
) )
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([]) const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[]
>([])
const [commentsByContract, setCommentsByContract] = useState< const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading' Map<Contract, Comment[]> | 'loading'
>('loading') >('loading')
@ -258,7 +261,7 @@ export function UserPage(props: {
{usersContracts !== 'loading' && commentsByContract != 'loading' ? ( {usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs <Tabs
className={'pb-2 pt-1 '} labelClassName={'pb-2 pt-1 '}
defaultIndex={ defaultIndex={
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0 defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
} }
@ -297,9 +300,9 @@ export function UserPage(props: {
title: 'Bets', title: 'Bets',
content: ( content: (
<div> <div>
{ <PortfolioValueSection
// TODO: add portfolio-value-section here portfolioHistory={portfolioHistory}
} />
<BetsList <BetsList
user={user} user={user}
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}

View File

@ -12,6 +12,7 @@ export function YesNoSelector(props: {
btnClassName?: string btnClassName?: string
replaceYesButton?: React.ReactNode replaceYesButton?: React.ReactNode
replaceNoButton?: React.ReactNode replaceNoButton?: React.ReactNode
isPseudoNumeric?: boolean
}) { }) {
const { const {
selected, selected,
@ -20,6 +21,7 @@ export function YesNoSelector(props: {
btnClassName, btnClassName,
replaceNoButton, replaceNoButton,
replaceYesButton, replaceYesButton,
isPseudoNumeric,
} = props } = props
const commonClassNames = const commonClassNames =
@ -41,7 +43,7 @@ export function YesNoSelector(props: {
)} )}
onClick={() => onSelect('YES')} onClick={() => onSelect('YES')}
> >
Bet YES {isPseudoNumeric ? 'HIGHER' : 'Bet YES'}
</button> </button>
)} )}
{replaceNoButton ? ( {replaceNoButton ? (
@ -58,7 +60,7 @@ export function YesNoSelector(props: {
)} )}
onClick={() => onSelect('NO')} onClick={() => onSelect('NO')}
> >
Bet NO {isPseudoNumeric ? 'LOWER' : 'Bet NO'}
</button> </button>
)} )}
</Row> </Row>

View File

@ -74,9 +74,7 @@ export function useMembers(group: Group) {
} }
export async function listMembers(group: Group) { export async function listMembers(group: Group) {
return (await Promise.all(group.memberIds.map(getUser))).filter( return await Promise.all(group.memberIds.map(getUser))
(user) => user
)
} }
export const useGroupsWithContract = (contractId: string | undefined) => { export const useGroupsWithContract = (contractId: string | undefined) => {

View File

@ -102,10 +102,13 @@ export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
console.error(`Group not found: ${groupSlug}`) console.error(`Group not found: ${groupSlug}`)
return return
} }
return await joinGroup(group, userId) return await addUserToGroup(group, userId)
} }
export async function joinGroup(group: Group, userId: string): Promise<Group> { export async function addUserToGroup(
group: Group,
userId: string
): Promise<Group> {
const { memberIds } = group const { memberIds } = group
if (memberIds.includes(userId)) { if (memberIds.includes(userId)) {
return group return group
@ -125,3 +128,14 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> {
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
return newGroup return newGroup
} }
export async function addContractToGroup(group: Group, contractId: string) {
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contractId]),
})
.then(() => group)
.catch((err) => {
console.error('error adding contract to group', err)
return err
})
}

View File

@ -144,10 +144,12 @@ export function ContractPageContent(
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const allowTrade = tradingAllowed(contract) const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user const allowResolve = !isResolved && isCreator && !!user
const hasSidePanel = (isBinary || isNumeric) && (allowTrade || allowResolve) const hasSidePanel =
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
const ogCardProps = getOpenGraphProps(contract) const ogCardProps = getOpenGraphProps(contract)
@ -170,7 +172,7 @@ export function ContractPageContent(
<BetPanel className="hidden xl:flex" contract={contract} /> <BetPanel className="hidden xl:flex" contract={contract} />
))} ))}
{allowResolve && {allowResolve &&
(isNumeric ? ( (isNumeric || isPseudoNumeric ? (
<NumericResolutionPanel creator={user} contract={contract} /> <NumericResolutionPanel creator={user} contract={contract} />
) : ( ) : (
<ResolutionPanel creator={user} contract={contract} /> <ResolutionPanel creator={user} contract={contract} />
@ -210,10 +212,11 @@ export function ContractPageContent(
)} )}
<ContractOverview contract={contract} bets={bets} /> <ContractOverview contract={contract} bets={bets} />
{isNumeric && ( {isNumeric && (
<AlertBox <AlertBox
title="Warning" title="Warning"
text="Numeric markets were introduced as an experimental feature and are now deprecated." text="Distributional numeric markets were introduced as an experimental feature and are now deprecated."
/> />
)} )}

View File

@ -19,7 +19,7 @@ import {
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { getGroup, updateGroup } from 'web/lib/firebase/groups' import { addContractToGroup, getGroup } from 'web/lib/firebase/groups'
import { Group } from 'common/group' import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -85,8 +85,12 @@ export function NewContract(props: {
const { creator, question, groupId } = props const { creator, question, groupId } = props
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
const [initialProb] = useState(50) const [initialProb] = useState(50)
const [minString, setMinString] = useState('') const [minString, setMinString] = useState('')
const [maxString, setMaxString] = useState('') const [maxString, setMaxString] = useState('')
const [isLogScale, setIsLogScale] = useState(false)
const [initialValueString, setInitialValueString] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
// const [tagText, setTagText] = useState<string>(tag ?? '') // const [tagText, setTagText] = useState<string>(tag ?? '')
// const tags = parseWordsAsTags(tagText) // const tags = parseWordsAsTags(tagText)
@ -129,6 +133,18 @@ export function NewContract(props: {
const min = minString ? parseFloat(minString) : undefined const min = minString ? parseFloat(minString) : undefined
const max = maxString ? parseFloat(maxString) : undefined const max = maxString ? parseFloat(maxString) : undefined
const initialValue = initialValueString
? parseFloat(initialValueString)
: undefined
const adjustIsLog = () => {
if (min === undefined || max === undefined) return
const lengthDiff = Math.log10(max - min)
if (lengthDiff > 2) {
setIsLogScale(true)
}
}
// get days from today until the end of this year: // get days from today until the end of this year:
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
@ -145,13 +161,16 @@ export function NewContract(props: {
// closeTime must be in the future // closeTime must be in the future
closeTime && closeTime &&
closeTime > Date.now() && closeTime > Date.now() &&
(outcomeType !== 'NUMERIC' || (outcomeType !== 'PSEUDO_NUMERIC' ||
(min !== undefined && (min !== undefined &&
max !== undefined && max !== undefined &&
initialValue !== undefined &&
isFinite(min) && isFinite(min) &&
isFinite(max) && isFinite(max) &&
min < max && min < max &&
max - min > 0.01)) max - min > 0.01 &&
min < initialValue &&
initialValue < max))
function setCloseDateInDays(days: number) { function setCloseDateInDays(days: number) {
const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD')
@ -175,6 +194,8 @@ export function NewContract(props: {
closeTime, closeTime,
min, min,
max, max,
initialValue,
isLogScale: (min ?? 0) < 0 ? false : isLogScale,
groupId: selectedGroup?.id, groupId: selectedGroup?.id,
tags: category ? [category] : undefined, tags: category ? [category] : undefined,
}) })
@ -186,9 +207,7 @@ export function NewContract(props: {
isFree: false, isFree: false,
}) })
if (result && selectedGroup) { if (result && selectedGroup) {
await updateGroup(selectedGroup, { await addContractToGroup(selectedGroup, result.id)
contractIds: [...selectedGroup.contractIds, result.id],
})
} }
await router.push(contractPath(result as Contract)) await router.push(contractPath(result as Contract))
@ -222,6 +241,7 @@ export function NewContract(props: {
choicesMap={{ choicesMap={{
'Yes / No': 'BINARY', 'Yes / No': 'BINARY',
'Free response': 'FREE_RESPONSE', 'Free response': 'FREE_RESPONSE',
Numeric: 'PSEUDO_NUMERIC',
}} }}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
className={'col-span-4'} className={'col-span-4'}
@ -234,38 +254,89 @@ export function NewContract(props: {
<Spacer h={6} /> <Spacer h={6} />
{outcomeType === 'NUMERIC' && ( {outcomeType === 'PSEUDO_NUMERIC' && (
<div className="form-control items-start"> <>
<label className="label gap-2"> <div className="form-control mb-2 items-start">
<span className="mb-1">Range</span> <label className="label gap-2">
<InfoTooltip text="The minimum and maximum numbers across the numeric range." /> <span className="mb-1">Range</span>
</label> <InfoTooltip text="The minimum and maximum numbers across the numeric range." />
</label>
<Row className="gap-2"> <Row className="gap-2">
<input <input
type="number" type="number"
className="input input-bordered" className="input input-bordered"
placeholder="MIN" placeholder="MIN"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)} onChange={(e) => setMinString(e.target.value)}
min={Number.MIN_SAFE_INTEGER} onBlur={adjustIsLog}
max={Number.MAX_SAFE_INTEGER} min={Number.MIN_SAFE_INTEGER}
disabled={isSubmitting} max={Number.MAX_SAFE_INTEGER}
value={minString ?? ''} disabled={isSubmitting}
/> value={minString ?? ''}
<input />
type="number" <input
className="input input-bordered" type="number"
placeholder="MAX" className="input input-bordered"
onClick={(e) => e.stopPropagation()} placeholder="MAX"
onChange={(e) => setMaxString(e.target.value)} onClick={(e) => e.stopPropagation()}
min={Number.MIN_SAFE_INTEGER} onChange={(e) => setMaxString(e.target.value)}
max={Number.MAX_SAFE_INTEGER} onBlur={adjustIsLog}
disabled={isSubmitting} min={Number.MIN_SAFE_INTEGER}
value={maxString} max={Number.MAX_SAFE_INTEGER}
/> disabled={isSubmitting}
</Row> value={maxString}
</div> />
</Row>
{!(min !== undefined && min < 0) && (
<Row className="mt-1 ml-2 mb-2 items-center">
<span className="mr-2 text-sm">Log scale</span>{' '}
<input
type="checkbox"
checked={isLogScale}
onChange={() => setIsLogScale(!isLogScale)}
disabled={isSubmitting}
/>
</Row>
)}
{min !== undefined && max !== undefined && min >= max && (
<div className="mt-2 mb-2 text-sm text-red-500">
The maximum value must be greater than the minimum.
</div>
)}
</div>
<div className="form-control mb-2 items-start">
<label className="label gap-2">
<span className="mb-1">Initial value</span>
<InfoTooltip text="The starting value for this market. Should be in between min and max values." />
</label>
<Row className="gap-2">
<input
type="number"
className="input input-bordered"
placeholder="Initial value"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setInitialValueString(e.target.value)}
max={Number.MAX_SAFE_INTEGER}
disabled={isSubmitting}
value={initialValueString ?? ''}
/>
</Row>
{initialValue !== undefined &&
min !== undefined &&
max !== undefined &&
min < max &&
(initialValue <= min || initialValue >= max) && (
<div className="mt-2 mb-2 text-sm text-red-500">
Initial value must be in between {min} and {max}.{' '}
</div>
)}
</div>
</>
)} )}
<div className="form-control max-w-[265px] items-start"> <div className="form-control max-w-[265px] items-start">

View File

@ -7,6 +7,7 @@ import {
BinaryResolutionOrChance, BinaryResolutionOrChance,
FreeResponseResolutionOrChance, FreeResponseResolutionOrChance,
NumericResolutionOrExpectation, NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation,
} from 'web/components/contract/contract-card' } from 'web/components/contract/contract-card'
import { ContractDetails } from 'web/components/contract/contract-details' import { ContractDetails } from 'web/components/contract/contract-details'
import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
@ -79,6 +80,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const { question, outcomeType } = contract const { question, outcomeType } = contract
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const href = `https://${DOMAIN}${contractPath(contract)}` const href = `https://${DOMAIN}${contractPath(contract)}`
@ -110,13 +112,18 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
{isBinary && ( {isBinary && (
<Row className="items-center gap-4"> <Row className="items-center gap-4">
{/* this fails typechecking, but it doesn't explode because we will <BetRow contract={contract} betPanelClassName="scale-75" />
never */}
<BetRow contract={contract as any} betPanelClassName="scale-75" />
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
</Row> </Row>
)} )}
{isPseudoNumeric && (
<Row className="items-center gap-4">
<BetRow contract={contract} betPanelClassName="scale-75" />
<PseudoNumericResolutionOrExpectation contract={contract} />
</Row>
)}
{outcomeType === 'FREE_RESPONSE' && ( {outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance <FreeResponseResolutionOrChance
contract={contract} contract={contract}
@ -133,7 +140,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
</div> </div>
<div className="mx-1" style={{ paddingBottom }}> <div className="mx-1" style={{ paddingBottom }}>
{isBinary && ( {(isBinary || isPseudoNumeric) && (
<ContractProbGraph <ContractProbGraph
contract={contract} contract={contract}
bets={bets} bets={bets}

View File

@ -4,12 +4,14 @@ import { Group } from 'common/group'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { listAllBets } from 'web/lib/firebase/bets' import { listAllBets } from 'web/lib/firebase/bets'
import { Contract, listenForUserContracts } from 'web/lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import { import {
groupPath, groupPath,
getGroupBySlug, getGroupBySlug,
getGroupContracts, getGroupContracts,
updateGroup, updateGroup,
addContractToGroup,
addUserToGroup,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
@ -39,7 +41,6 @@ import React, { useEffect, useState } from 'react'
import { GroupChat } from 'web/components/groups/group-chat' import { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { PlusIcon } from '@heroicons/react/outline'
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
@ -48,6 +49,7 @@ import ShortToggle from 'web/components/widgets/short-toggle'
import { ShareIconButton } from 'web/components/share-icon-button' import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user' import { REFERRAL_AMOUNT } from 'common/user'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { ContractSearch } from 'web/components/contract-search'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -509,75 +511,46 @@ function GroupLeaderboards(props: {
} }
function AddContractButton(props: { group: Group; user: User }) { function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props const { group } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
const [query, setQuery] = useState('')
useEffect(() => { async function addContractToCurrentGroup(contract: Contract) {
return listenForUserContracts(user.id, (contracts) => { await addContractToGroup(group, contract.id)
setContracts(contracts.filter((c) => !group.contractIds.includes(c.id)))
})
}, [group.contractIds, user.id])
async function addContractToGroup(contract: Contract) {
await updateGroup(group, {
...group,
contractIds: [...group.contractIds, contract.id],
})
setOpen(false) setOpen(false)
} }
// TODO use find-active-contracts to sort by?
const matches = sortBy(contracts, [
(contract) => -1 * contract.createdTime,
]).filter(
(c) =>
checkAgainstQuery(query, c.question) ||
checkAgainstQuery(query, c.description) ||
checkAgainstQuery(query, c.tags.flat().join(' '))
)
const debouncedQuery = debounce(setQuery, 50)
return ( return (
<> <>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen} className={'sm:p-0'}>
<Col className={'max-h-[60vh] w-full gap-4 rounded-md bg-white p-8'}> <Col
className={
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8'
}
>
<div className={'text-lg text-indigo-700'}> <div className={'text-lg text-indigo-700'}>
Add a question to your group Add a question to your group
</div> </div>
<input <div className={'overflow-y-scroll p-1'}>
type="text" <ContractSearch
onChange={(e) => debouncedQuery(e.target.value)} hideOrderSelector={true}
placeholder="Search your questions" onContractClick={addContractToCurrentGroup}
className="input input-bordered mb-4 w-full" showCategorySelector={false}
/> overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'}
<div className={'overflow-y-scroll'}> showPlaceHolder={true}
{contracts ? ( hideQuickBet={true}
<ContractsGrid additionalFilter={{ excludeContractIds: group.contractIds }}
contracts={matches} />
loadMore={() => {}}
hasMore={false}
onContractClick={(contract) => {
addContractToGroup(contract)
}}
overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'}
hideQuickBet={true}
/>
) : (
<LoadingIndicator />
)}
</div> </div>
</Col> </Col>
</Modal> </Modal>
<Row className={'items-center justify-center'}> <Row className={'items-center justify-center'}>
<button <button
className={ className={
'btn btn-sm btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case' 'btn btn-md btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case'
} }
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<PlusIcon className="mr-1 h-5 w-5" /> Add an old question
Add old questions to this group
</button> </button>
</Row> </Row>
</> </>
@ -591,17 +564,11 @@ function JoinGroupButton(props: {
const { group, user } = props const { group, user } = props
function joinGroup() { function joinGroup() {
if (user && !group.memberIds.includes(user.id)) { if (user && !group.memberIds.includes(user.id)) {
toast.promise( toast.promise(addUserToGroup(group, user.id), {
updateGroup(group, { loading: 'Joining group...',
...group, success: 'Joined group!',
memberIds: [...group.memberIds, user.id], error: "Couldn't join group",
}), })
{
loading: 'Joining group...',
success: 'Joined group!',
error: "Couldn't join group",
}
)
} }
} }
return ( return (

View File

@ -67,7 +67,9 @@ export default function Leaderboards(props: {
<Col className="mx-4 items-center gap-10 lg:flex-row"> <Col className="mx-4 items-center gap-10 lg:flex-row">
{!isLoading ? ( {!isLoading ? (
<> <>
{period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available {period === 'allTime' ||
period == 'weekly' ||
period === 'daily' ? ( //TODO: show other periods once they're available
<Leaderboard <Leaderboard
title="🏅 Top bettors" title="🏅 Top bettors"
users={topTradersState} users={topTradersState}

View File

@ -64,7 +64,7 @@ export default function LinkPage() {
<Col className="w-full px-8"> <Col className="w-full px-8">
<Title text="Manalinks" /> <Title text="Manalinks" />
<Tabs <Tabs
className={'pb-2 pt-1 '} labelClassName={'pb-2 pt-1 '}
defaultIndex={0} defaultIndex={0}
tabs={[ tabs={[
{ {

View File

@ -86,7 +86,7 @@ export default function Notifications() {
<div className={'p-2 sm:p-4'}> <div className={'p-2 sm:p-4'}>
<Title text={'Notifications'} className={'hidden md:block'} /> <Title text={'Notifications'} className={'hidden md:block'} />
<Tabs <Tabs
className={'pb-2 pt-1 '} labelClassName={'pb-2 pt-1 '}
defaultIndex={0} defaultIndex={0}
tabs={[ tabs={[
{ {

View File

@ -3875,13 +3875,6 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
biskviit@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/biskviit/-/biskviit-1.0.1.tgz#037a0cd4b71b9e331fd90a1122de17dc49e420a7"
integrity sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w==
dependencies:
psl "^1.1.7"
bluebird@^3.7.1: bluebird@^3.7.1:
version "3.7.2" version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
@ -5237,13 +5230,6 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
encoding@0.1.12:
version "0.1.12"
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=
dependencies:
iconv-lite "~0.4.13"
end-of-stream@^1.1.0, end-of-stream@^1.4.1: end-of-stream@^1.1.0, end-of-stream@^1.4.1:
version "1.4.4" version "1.4.4"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
@ -5817,14 +5803,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4:
node-domexception "^1.0.0" node-domexception "^1.0.0"
web-streams-polyfill "^3.0.3" web-streams-polyfill "^3.0.3"
fetch@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fetch/-/fetch-1.1.0.tgz#0a8279f06be37f9f0ebb567560a30a480da59a2e"
integrity sha1-CoJ58Gvjf58Ou1Z1YKMKSA2lmi4=
dependencies:
biskviit "1.0.1"
encoding "0.1.12"
file-entry-cache@^6.0.1: file-entry-cache@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
@ -6782,7 +6760,7 @@ human-signals@^2.1.0:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
iconv-lite@0.4.24, iconv-lite@~0.4.13: iconv-lite@0.4.24:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -9151,11 +9129,6 @@ pseudomap@^1.0.1:
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
psl@^1.1.7:
version "1.8.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
pump@^3.0.0: pump@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"