From 76f27d1a93769d90e98167a7295de69d12882531 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 19 May 2022 12:42:03 -0500 Subject: [PATCH] Numeric range markets!! (#146) * Numeric contract type * Create market numeric type * Add numeric graph (coded without testing) * Outline of numeric bet panel * Update bet panel logic * create numeric contracts * remove batching for antes for numeric markets * Remove focus * numeric market range [1, 100] * Zoom graph * Hide bet panels * getNumericBets * Add numeric resolution panel * Use getNumericBets in bet panel calc * Switch bucket count to 100 * Parallelize ante creation * placeBet for numeric markets * halve std of numeric bets * Update resolveMarket with numeric type * Set min and max for contract * lower std for numeric bets * calculateNumericDpmShares: use sorted order * Use min and max to map the input * Fix probability calc * normpdf variance mislabeled * range input * merge * change numeric graph color * fix getNewContract params * bet panel labels * validation * number input * fix bucketing * bucket input, numeric resolution panel * outcome label * merge * numeric bet panel on mobile * Make text underneath filled green answer bar selectable * Default to 'all' feed category when loading page. * fix numeric resolution panel * fix numeric bet panel calculations * display numeric resolution * don't render NumericBetPanel for non numeric markets * numeric bets: store shares, bet amounts across buckets in each bet object * restore your bets for numeric markets * numeric pnl calculations * remove hasUserHitManaLimit * contrain contract type * handle undefined allOutcomeShares * numeric ante bet amount * use correct amount for numeric dpm payouts * change numeric graph/outcome color * numeric constants * hack to show correct numeric payout in calculateDpmPayoutAfterCorrectBet * remove comment * fix ante display in bet list * halve bucket count * cast to NumericContract * fix merge imports * OUTCOME_TYPES * typo * lower bucket count to 200 * store raw numeric value with bet * store raw numeric resolution value * number input max length * create page: min, max to undefined if not numeric market * numeric resolution formatting * expected value for numeric markets * expected value for numeric markets * rearrange lines for readability * move normalpdf to util/math * show bets tab * check if outcomeMode is undefined * remove extraneous auto-merge cruft * hide comment status for numeric markets * import Co-authored-by: mantikoros --- common/antes.ts | 53 ++++- common/bet.ts | 6 + common/calculate-dpm.ts | 176 +++++++++++++-- common/calculate.ts | 27 --- common/contract.ts | 23 +- common/new-bet.ts | 56 ++++- common/new-contract.ts | 41 ++++ common/numeric-constants.ts | 5 + common/payouts-dpm.ts | 60 ++++- common/payouts.ts | 6 +- common/util/math.ts | 13 ++ functions/src/create-answer.ts | 8 - functions/src/create-contract.ts | 46 +++- functions/src/emails.ts | 8 + functions/src/place-bet.ts | 23 +- functions/src/resolve-market.ts | 10 +- web/components/bets-list.tsx | 30 ++- web/components/bucket-input.tsx | 43 ++++ web/components/contract/contract-card.tsx | 38 ++++ web/components/contract/contract-overview.tsx | 38 +++- web/components/contract/contract-tabs.tsx | 3 +- web/components/contract/numeric-graph.tsx | 76 +++++++ web/components/feed/feed-bets.tsx | 1 + web/components/feed/feed-comments.tsx | 60 ++--- web/components/feed/feed-items.tsx | 5 + web/components/number-input.tsx | 62 ++++++ web/components/numeric-bet-panel.tsx | 207 ++++++++++++++++++ web/components/numeric-resolution-panel.tsx | 101 +++++++++ web/components/outcome-label.tsx | 12 +- web/components/yes-no-selector.tsx | 31 +++ web/lib/firebase/api-call.ts | 1 + web/pages/[username]/[contractSlug].tsx | 49 ++++- web/pages/create.tsx | 61 +++++- web/pages/embed/[username]/[contractSlug].tsx | 8 + web/pages/home.tsx | 3 +- 35 files changed, 1261 insertions(+), 129 deletions(-) create mode 100644 common/numeric-constants.ts create mode 100644 web/components/bucket-input.tsx create mode 100644 web/components/contract/numeric-graph.tsx create mode 100644 web/components/number-input.tsx create mode 100644 web/components/numeric-bet-panel.tsx create mode 100644 web/components/numeric-resolution-panel.tsx diff --git a/common/antes.ts b/common/antes.ts index c77308f4..a6bd733a 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -1,9 +1,17 @@ -import { Bet } from './bet' -import { getDpmProbability } from './calculate-dpm' -import { Binary, CPMM, DPM, FreeResponse, FullContract } from './contract' +import { Bet, NumericBet } from './bet' +import { getDpmProbability, getValueFromBucket } from './calculate-dpm' +import { + Binary, + CPMM, + DPM, + FreeResponse, + FullContract, + Numeric, +} from './contract' import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' +import * as _ from 'lodash' export const FIXED_ANTE = 100 @@ -106,3 +114,42 @@ export function getFreeAnswerAnte( return anteBet } + +export function getNumericAnte( + creator: User, + contract: FullContract, + ante: number, + newBetId: string +) { + const { bucketCount, createdTime } = contract + + const betAnte = ante / bucketCount + const betShares = Math.sqrt(ante ** 2 / bucketCount) + + const allOutcomeShares = Object.fromEntries( + _.range(0, bucketCount).map((_, i) => [i, betShares]) + ) + + const allBetAmounts = Object.fromEntries( + _.range(0, bucketCount).map((_, i) => [i, betAnte]) + ) + + const anteBet: NumericBet = { + id: newBetId, + userId: creator.id, + contractId: contract.id, + amount: ante, + allBetAmounts, + outcome: '0', + value: getValueFromBucket('0', contract), + shares: betShares, + allOutcomeShares, + probBefore: 0, + probAfter: 1 / bucketCount, + createdTime, + isAnte: true, + fees: noFees, + } + + return anteBet +} diff --git a/common/bet.ts b/common/bet.ts index 3ef71ed6..993a2fac 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -29,4 +29,10 @@ export type Bet = { createdTime: number } +export type NumericBet = Bet & { + value: number + allOutcomeShares: { [outcome: string]: number } + allBetAmounts: { [outcome: string]: number } +} + export const MAX_LOAN_PER_CONTRACT = 20 diff --git a/common/calculate-dpm.ts b/common/calculate-dpm.ts index 976d1b57..2ec9fd88 100644 --- a/common/calculate-dpm.ts +++ b/common/calculate-dpm.ts @@ -1,7 +1,17 @@ import * as _ from 'lodash' -import { Bet } from './bet' -import { Binary, DPM, FreeResponse, FullContract } from './contract' + +import { Bet, NumericBet } from './bet' +import { + Binary, + DPM, + FreeResponse, + FullContract, + Numeric, + NumericContract, +} from './contract' import { DPM_FEES } from './fees' +import { normpdf } from '../common/util/math' +import { addObjects } from './util/object' export function getDpmProbability(totalShares: { [outcome: string]: number }) { // For binary contracts only. @@ -19,6 +29,91 @@ export function getDpmOutcomeProbability( return shares ** 2 / squareSum } +export function getDpmOutcomeProbabilities(totalShares: { + [outcome: string]: number +}) { + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + return _.mapValues(totalShares, (shares) => shares ** 2 / squareSum) +} + +export function getNumericBets( + contract: NumericContract, + bucket: string, + betAmount: number, + variance: number +) { + const { bucketCount } = contract + const bucketNumber = parseInt(bucket) + const buckets = _.range(0, bucketCount) + + const mean = bucketNumber / bucketCount + + const allDensities = buckets.map((i) => + normpdf(i / bucketCount, mean, variance) + ) + const densitySum = _.sum(allDensities) + + const rawBetAmounts = allDensities + .map((d) => (d / densitySum) * betAmount) + .map((x) => (x >= 1 / bucketCount ? x : 0)) + + const rawSum = _.sum(rawBetAmounts) + const scaledBetAmounts = rawBetAmounts.map((x) => (x / rawSum) * betAmount) + + const bets = scaledBetAmounts + .map((x, i) => (x > 0 ? [i.toString(), x] : undefined)) + .filter((x) => x != undefined) as [string, number][] + + return bets +} + +export const getMappedBucket = (value: number, contract: NumericContract) => { + const { bucketCount, min, max } = contract + + const index = Math.floor(((value - min) / (max - min)) * bucketCount) + const bucket = Math.max(Math.min(index, bucketCount - 1), 0) + + return `${bucket}` +} + +export const getValueFromBucket = ( + bucket: string, + contract: NumericContract +) => { + const { bucketCount, min, max } = contract + const index = parseInt(bucket) + const value = min + (index / bucketCount) * (max - min) + const rounded = Math.round(value * 1e4) / 1e4 + return rounded +} + +export const getExpectedValue = (contract: NumericContract) => { + const { bucketCount, min, max, totalShares } = contract + + const totalShareSum = _.sumBy( + Object.values(totalShares), + (shares) => shares ** 2 + ) + const probs = _.range(0, bucketCount).map( + (i) => totalShares[i] ** 2 / totalShareSum + ) + + const values = _.range(0, bucketCount).map( + (i) => + // use mid point within bucket + 0.5 * (min + (i / bucketCount) * (max - min)) + + 0.5 * (min + ((i + 1) / bucketCount) * (max - min)) + ) + + const weightedValues = _.range(0, bucketCount).map( + (i) => probs[i] * values[i] + ) + + const expectation = _.sum(weightedValues) + const rounded = Math.round(expectation * 1e2) / 1e2 + return rounded +} + export function getDpmOutcomeProbabilityAfterBet( totalShares: { [outcome: string]: number @@ -63,6 +158,30 @@ export function calculateDpmShares( return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares } +export function calculateNumericDpmShares( + totalShares: { + [outcome: string]: number + }, + bets: [string, number][] +) { + const shares: number[] = [] + + totalShares = _.cloneDeep(totalShares) + + const order = _.sortBy( + bets.map(([, amount], i) => [amount, i]), + ([amount]) => amount + ).map(([, i]) => i) + + for (let i of order) { + const [bucket, bet] = bets[i] + shares[i] = calculateDpmShares(totalShares, bet, bucket) + totalShares = addObjects(totalShares, { [bucket]: shares[i] }) + } + + return { shares, totalShares } +} + export function calculateDpmRawShareValue( totalShares: { [outcome: string]: number @@ -163,8 +282,15 @@ export function calculateStandardDpmPayout( bet: Bet, outcome: string ) { - const { amount, outcome: betOutcome, shares } = bet - if (betOutcome !== outcome) return 0 + const { outcome: betOutcome } = bet + const isNumeric = contract.outcomeType === 'NUMERIC' + if (!isNumeric && betOutcome !== outcome) return 0 + + const shares = isNumeric + ? ((bet as NumericBet).allOutcomeShares ?? {})[outcome] + : bet.shares + + if (!shares) return 0 const { totalShares, phantomShares, pool } = contract if (!totalShares[outcome]) return 0 @@ -175,15 +301,20 @@ export function calculateStandardDpmPayout( totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0) const winnings = (shares / total) * poolTotal - // profit can be negative if using phantom shares - return amount + (1 - DPM_FEES) * Math.max(0, winnings - amount) + + const amount = isNumeric + ? (bet as NumericBet).allBetAmounts[outcome] + : bet.amount + + const payout = amount + (1 - DPM_FEES) * Math.max(0, winnings - amount) + return payout } export function calculateDpmPayoutAfterCorrectBet( contract: FullContract, bet: Bet ) { - const { totalShares, pool, totalBets } = contract + const { totalShares, pool, totalBets, outcomeType } = contract const { shares, amount, outcome } = bet const prevShares = totalShares[outcome] ?? 0 @@ -204,19 +335,23 @@ export function calculateDpmPayoutAfterCorrectBet( ...totalBets, [outcome]: prevTotalBet + amount, }, + outcomeType: + outcomeType === 'NUMERIC' + ? 'FREE_RESPONSE' // hack to show payout at particular bet point estimate + : outcomeType, } return calculateStandardDpmPayout(newContract, bet, outcome) } -function calculateMktDpmPayout(contract: FullContract, bet: Bet) { +function calculateMktDpmPayout( + contract: FullContract, + bet: Bet +) { if (contract.outcomeType === 'BINARY') return calculateBinaryMktDpmPayout(contract, bet) - const { totalShares, pool, resolutions } = contract as FullContract< - DPM, - FreeResponse - > + const { totalShares, pool, resolutions, outcomeType } = contract let probs: { [outcome: string]: number } @@ -239,10 +374,21 @@ function calculateMktDpmPayout(contract: FullContract, bet: Bet) { const { outcome, amount, shares } = bet - const totalPool = _.sum(Object.values(pool)) - const poolFrac = (probs[outcome] * shares) / weightedShareTotal - const winnings = poolFrac * totalPool + const poolFrac = + outcomeType === 'NUMERIC' + ? _.sumBy( + Object.keys((bet as NumericBet).allOutcomeShares ?? {}), + (outcome) => { + return ( + (probs[outcome] * (bet as NumericBet).allOutcomeShares[outcome]) / + weightedShareTotal + ) + } + ) + : (probs[outcome] * shares) / weightedShareTotal + const totalPool = _.sum(Object.values(pool)) + const winnings = poolFrac * totalPool return deductDpmFees(amount, winnings) } diff --git a/common/calculate.ts b/common/calculate.ts index 4c11a660..06820edc 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -161,7 +161,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { return { invested: Math.max(0, currentInvested), - currentInvested, payout, netPayout, profit, @@ -190,29 +189,3 @@ export function getTopAnswer(contract: FreeResponseContract) { ) return top?.answer } - -export function hasUserHitManaLimit( - contract: FreeResponseContract, - bets: Bet[], - amount: number -) { - const { manaLimitPerUser } = contract - if (manaLimitPerUser) { - const contractMetrics = getContractBetMetrics(contract, bets) - const currentInvested = contractMetrics.currentInvested - console.log('user current invested amount', currentInvested) - console.log('mana limit:', manaLimitPerUser) - - if (currentInvested + amount > manaLimitPerUser) { - const manaAllowed = manaLimitPerUser - currentInvested - return { - status: 'error', - message: `Market bet cap is M$${manaLimitPerUser}, you've M$${manaAllowed} left`, - } - } - } - return { - status: 'success', - message: '', - } -} diff --git a/common/contract.ts b/common/contract.ts index 3434d6d6..14499cc7 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,9 +1,10 @@ +import * as _ from 'lodash' import { Answer } from './answer' import { Fees } from './fees' export type FullContract< M extends DPM | CPMM, - T extends Binary | Multi | FreeResponse + T extends Binary | Multi | FreeResponse | Numeric > = { id: string slug: string // auto-generated; must be unique @@ -11,7 +12,7 @@ export type FullContract< creatorId: string creatorName: string creatorUsername: string - creatorAvatarUrl?: string // Start requiring after 2022-03-01 + creatorAvatarUrl?: string question: string description: string // More info about what the contract is about @@ -41,9 +42,13 @@ export type FullContract< } & M & T -export type Contract = FullContract +export type Contract = FullContract< + DPM | CPMM, + Binary | Multi | FreeResponse | Numeric +> export type BinaryContract = FullContract export type FreeResponseContract = FullContract +export type NumericContract = FullContract export type DPM = { mechanism: 'dpm-2' @@ -83,7 +88,17 @@ export type FreeResponse = { resolutions?: { [outcome: string]: number } // Used for MKT resolution. } -export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' +export type Numeric = { + outcomeType: 'NUMERIC' + bucketCount: number + min: number + max: number + resolutions?: { [outcome: string]: number } // Used for MKT resolution. + resolutionValue?: number +} + +export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' | 'NUMERIC' +export const OUTCOME_TYPES = ['BINARY', 'MULTI', 'FREE_RESPONSE', 'NUMERIC'] export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/new-bet.ts b/common/new-bet.ts index 92feb715..1e09d23d 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,10 +1,12 @@ import * as _ from 'lodash' -import { Bet, MAX_LOAN_PER_CONTRACT } from './bet' +import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { calculateDpmShares, getDpmProbability, getDpmOutcomeProbability, + getNumericBets, + calculateNumericDpmShares, } from './calculate-dpm' import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' import { @@ -14,9 +16,12 @@ import { FreeResponse, FullContract, Multi, + NumericContract, } from './contract' import { User } from './user' import { noFees } from './fees' +import { addObjects } from './util/object' +import { NUMERIC_FIXED_VAR } from './numeric-constants' export const getNewBinaryCpmmBetInfo = ( user: User, @@ -154,6 +159,55 @@ export const getNewMultiBetInfo = ( return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } +export const getNumericBetsInfo = ( + user: User, + value: number, + outcome: string, + amount: number, + contract: NumericContract, + newBetId: string +) => { + const { pool, totalShares, totalBets } = contract + + const bets = getNumericBets(contract, outcome, amount, NUMERIC_FIXED_VAR) + + const allBetAmounts = Object.fromEntries(bets) + const newTotalBets = addObjects(totalBets, allBetAmounts) + const newPool = addObjects(pool, allBetAmounts) + + const { shares, totalShares: newTotalShares } = calculateNumericDpmShares( + contract.totalShares, + bets + ) + + const allOutcomeShares = Object.fromEntries( + bets.map(([outcome], i) => [outcome, shares[i]]) + ) + + const probBefore = getDpmOutcomeProbability(totalShares, outcome) + const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) + + const newBet: NumericBet = { + id: newBetId, + userId: user.id, + contractId: contract.id, + value, + amount, + allBetAmounts, + shares: shares.find((s, i) => bets[i][0] === outcome) ?? 0, + allOutcomeShares, + outcome, + probBefore, + probAfter, + createdTime: Date.now(), + fees: noFees, + } + + const newBalance = user.balance - amount + + return { newBet, newPool, newTotalShares, newTotalBets, newBalance } +} + export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => { const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale) const prevLoanAmount = _.sumBy(openBets, (bet) => bet.loanAmount ?? 0) diff --git a/common/new-contract.ts b/common/new-contract.ts index f6b6829c..b0064cc7 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -1,3 +1,5 @@ +import * as _ from 'lodash' + import { PHANTOM_ANTE } from './antes' import { Binary, @@ -5,6 +7,7 @@ import { CPMM, DPM, FreeResponse, + Numeric, outcomeType, } from './contract' import { User } from './user' @@ -23,6 +26,11 @@ export function getNewContract( ante: number, closeTime: number, extraTags: string[], + + // used for numeric markets + bucketCount: number, + min: number, + max: number, manaLimitPerUser: number ) { const tags = parseTags( @@ -33,6 +41,8 @@ export function getNewContract( const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) + : outcomeType === 'NUMERIC' + ? getNumericProps(ante, bucketCount, min, max) : getFreeAnswerProps(ante) const contract: Contract = removeUndefinedProps({ @@ -115,6 +125,37 @@ const getFreeAnswerProps = (ante: number) => { return system } +const getNumericProps = ( + ante: number, + bucketCount: number, + min: number, + max: number +) => { + const buckets = _.range(0, bucketCount).map((i) => i.toString()) + + const betAnte = ante / bucketCount + const pool = Object.fromEntries(buckets.map((answer) => [answer, betAnte])) + const totalBets = pool + + const betShares = Math.sqrt(ante ** 2 / bucketCount) + const totalShares = Object.fromEntries( + buckets.map((answer) => [answer, betShares]) + ) + + const system: DPM & Numeric = { + mechanism: 'dpm-2', + outcomeType: 'NUMERIC', + pool, + totalBets, + totalShares, + bucketCount, + min, + max, + } + + return system +} + const getMultiProps = ( outcomes: string[], initialProbs: number[], diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts new file mode 100644 index 00000000..ef364b74 --- /dev/null +++ b/common/numeric-constants.ts @@ -0,0 +1,5 @@ +export const NUMERIC_BUCKET_COUNT = 200 +export const NUMERIC_FIXED_VAR = 0.005 + +export const NUMERIC_GRAPH_COLOR = '#5fa5f9' +export const NUMERIC_TEXT_COLOR = 'text-blue-500' diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index 64c7f90e..50e0b14c 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -1,6 +1,6 @@ import * as _ from 'lodash' -import { Bet } from './bet' +import { Bet, NumericBet } from './bet' import { deductDpmFees, getDpmProbability } from './calculate-dpm' import { DPM, FreeResponse, FullContract, Multi } from './contract' import { @@ -88,6 +88,64 @@ export const getDpmStandardPayouts = ( } } +export const getNumericDpmPayouts = ( + outcome: string, + contract: FullContract, + bets: NumericBet[] +) => { + const totalShares = _.sumBy(bets, (bet) => bet.allOutcomeShares[outcome] ?? 0) + const winningBets = bets.filter((bet) => !!bet.allOutcomeShares[outcome]) + + const poolTotal = _.sum(Object.values(contract.pool)) + + const payouts = winningBets.map( + ({ userId, allBetAmounts, allOutcomeShares }) => { + const shares = allOutcomeShares[outcome] ?? 0 + const winnings = (shares / totalShares) * poolTotal + + const amount = allBetAmounts[outcome] ?? 0 + const profit = winnings - amount + + // profit can be negative if using phantom shares + const payout = amount + (1 - DPM_FEES) * Math.max(0, profit) + return { userId, profit, payout } + } + ) + + const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit)) + const creatorFee = DPM_CREATOR_FEE * profits + const platformFee = DPM_PLATFORM_FEE * profits + + const finalFees: Fees = { + creatorFee, + platformFee, + liquidityFee: 0, + } + + const collectedFees = addObjects( + finalFees, + contract.collectedFees ?? {} + ) + + console.log( + 'resolved numeric bucket: ', + outcome, + 'pool', + poolTotal, + 'profits', + profits, + 'creator fee', + creatorFee + ) + + return { + payouts: payouts.map(({ userId, payout }) => ({ userId, payout })), + creatorPayout: creatorFee, + liquidityPayouts: [], + collectedFees, + } +} + export const getDpmMktPayouts = ( contract: FullContract, bets: Bet[], diff --git a/common/payouts.ts b/common/payouts.ts index 33f58120..aa1bbeb8 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,6 +1,6 @@ import * as _ from 'lodash' -import { Bet } from './bet' +import { Bet, NumericBet } from './bet' import { Binary, Contract, @@ -16,6 +16,7 @@ import { getDpmCancelPayouts, getDpmMktPayouts, getDpmStandardPayouts, + getNumericDpmPayouts, getPayoutsMultiOutcome, } from './payouts-dpm' import { @@ -131,6 +132,9 @@ export const getDpmPayouts = ( return getDpmCancelPayouts(contract, openBets) default: + if (contract.outcomeType === 'NUMERIC') + return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[]) + // Outcome is a free response answer id. return getDpmStandardPayouts(outcome, contract, openBets) } diff --git a/common/util/math.ts b/common/util/math.ts index f89e9d85..a255c19f 100644 --- a/common/util/math.ts +++ b/common/util/math.ts @@ -4,3 +4,16 @@ export const logInterpolation = (min: number, max: number, value: number) => { return Math.log(value - min + 1) / Math.log(max - min + 1) } + +export function normpdf(x: number, mean = 0, variance = 1) { + if (variance === 0) { + return x === mean ? Infinity : 0 + } + + return ( + Math.exp((-0.5 * Math.pow(x - mean, 2)) / variance) / + Math.sqrt(TAU * variance) + ) +} + +const TAU = Math.PI * 2 diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index aa18351d..bc74dc05 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -13,7 +13,6 @@ import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' import { Bet } from '../../common/bet' -import { hasUserHitManaLimit } from '../../common/calculate' export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -67,13 +66,6 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( ) const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) - const { status, message } = hasUserHitManaLimit( - contract, - yourBets, - amount - ) - if (status === 'error') return { status, message: message } - const [lastAnswer] = await getValues( firestore .collection(`contracts/${contractId}/answers`) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 988624d6..68857e40 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -1,7 +1,5 @@ import * as admin from 'firebase-admin' -import { chargeUser } from './utils' -import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' import { Binary, Contract, @@ -12,19 +10,27 @@ import { MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, + Numeric, + OUTCOME_TYPES, } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { getNewContract } from '../../common/new-contract' + +import { chargeUser } from './utils' +import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' + import { FIXED_ANTE, getAnteBets, getCpmmInitialLiquidity, getFreeAnswerAnte, + getNumericAnte, HOUSE_LIQUIDITY_PROVIDER_ID, MINIMUM_ANTE, } from '../../common/antes' import { getNoneAnswer } from '../../common/answer' +import { getNewContract } from '../../common/new-contract' +import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' export const createContract = newEndpoint(['POST'], async (req, _res) => { const [creator, _privateUser] = await lookupUser(await parseCredentials(req)) @@ -35,6 +41,8 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { initialProb, closeTime, tags, + min, + max, manaLimitPerUser, } = req.body.data || {} @@ -56,9 +64,23 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { ) outcomeType = outcomeType ?? 'BINARY' - if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) + + if (!OUTCOME_TYPES.includes(outcomeType)) throw new APIError(400, 'Invalid outcomeType') + if ( + outcomeType === 'NUMERIC' && + !( + min !== undefined && + max !== undefined && + isFinite(min) && + isFinite(max) && + min < max && + max - min > 0.01 + ) + ) + throw new APIError(400, 'Invalid range') + if ( outcomeType === 'BINARY' && (!initialProb || initialProb < 1 || initialProb > 99) @@ -109,6 +131,9 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { ante, closeTime, tags ?? [], + NUMERIC_BUCKET_COUNT, + min ?? 0, + max ?? 0, manaLimitPerUser ?? 0 ) @@ -167,6 +192,19 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { contract as FullContract, anteBetDoc.id ) + await anteBetDoc.set(anteBet) + } else if (outcomeType === 'NUMERIC') { + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + + const anteBet = getNumericAnte( + creator, + contract as FullContract, + ante, + anteBetDoc.id + ) + await anteBetDoc.set(anteBet) } } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index a73208a6..2b7db259 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -9,6 +9,8 @@ import { Contract, FreeResponseContract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' import { formatMoney, formatPercent } from '../../common/util/format' +import { getValueFromBucket } from '../../common/calculate-dpm' + import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -104,6 +106,12 @@ const toDisplayResolution = ( if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' + if (contract.outcomeType === 'NUMERIC' && contract.mechanism === 'dpm-2') + return ( + contract.resolutionValue?.toString() ?? + getValueFromBucket(resolution, contract).toString() + ) + const answer = (contract as FreeResponseContract).answers?.find( (a) => a.id === resolution ) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index cdaf2215..a95b46de 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -7,16 +7,16 @@ import { getNewBinaryCpmmBetInfo, getNewBinaryDpmBetInfo, getNewMultiBetInfo, + getNumericBetsInfo, } from '../../common/new-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { Bet } from '../../common/bet' import { redeemShares } from './redeem-shares' import { Fees } from '../../common/fees' -import { hasUserHitManaLimit } from '../../common/calculate' export const placeBet = newEndpoint(['POST'], async (req, _res) => { const [bettor, _privateUser] = await lookupUser(await parseCredentials(req)) - const { amount, outcome, contractId } = req.body.data || {} + const { amount, outcome, contractId, value } = req.body.data || {} if (amount <= 0 || isNaN(amount) || !isFinite(amount)) throw new APIError(400, 'Invalid amount') @@ -24,6 +24,9 @@ export const placeBet = newEndpoint(['POST'], async (req, _res) => { if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) throw new APIError(400, 'Invalid outcome') + if (value !== undefined && !isFinite(value)) + throw new APIError(400, 'Invalid value') + // run as transaction to prevent race conditions return await firestore .runTransaction(async (transaction) => { @@ -55,13 +58,6 @@ export const placeBet = newEndpoint(['POST'], async (req, _res) => { contractDoc.collection('answers').doc(outcome) ) if (!answerSnap.exists) throw new APIError(400, 'Invalid contract') - - const { status, message } = hasUserHitManaLimit( - contract, - yourBets, - amount - ) - if (status === 'error') throw new APIError(400, message) } const newBetDoc = firestore @@ -96,6 +92,15 @@ export const placeBet = newEndpoint(['POST'], async (req, _res) => { loanAmount, newBetDoc.id ) as any) + : outcomeType === 'NUMERIC' && mechanism === 'dpm-2' + ? getNumericBetsInfo( + user, + value, + outcome, + amount, + contract, + newBetDoc.id + ) : getNewMultiBetInfo( user, outcome, diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 0ef416a7..3108fe07 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -22,6 +22,7 @@ export const resolveMarket = functions async ( data: { outcome: string + value?: number contractId: string probabilityInt?: number resolutions?: { [outcome: string]: number } @@ -31,7 +32,7 @@ export const resolveMarket = functions const userId = context?.auth?.uid if (!userId) return { status: 'error', message: 'Not authorized' } - const { outcome, contractId, probabilityInt, resolutions } = data + const { outcome, contractId, probabilityInt, resolutions, value } = data const contractDoc = firestore.doc(`contracts/${contractId}`) const contractSnap = await contractDoc.get() @@ -50,10 +51,16 @@ export const resolveMarket = functions outcome !== 'CANCEL' ) return { status: 'error', message: 'Invalid outcome' } + } else if (outcomeType === 'NUMERIC') { + if (isNaN(+outcome) && outcome !== 'CANCEL') + return { status: 'error', message: 'Invalid outcome' } } else { return { status: 'error', message: 'Invalid contract outcomeType' } } + if (value !== undefined && !isFinite(value)) + return { status: 'error', message: 'Invalid value' } + if ( outcomeType === 'BINARY' && probabilityInt !== undefined && @@ -108,6 +115,7 @@ export const resolveMarket = functions removeUndefinedProps({ isResolved: true, resolution: outcome, + resolutionValue: value, resolutionTime, closeTime: newCloseTime, resolutionProbability, diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index b440dca0..03c09ef0 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -39,6 +39,7 @@ import { } from 'common/calculate' import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { trackLatency } from 'web/lib/firebase/tracking' +import { NumericContract } from 'common/contract' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'closed' | 'resolved' | 'all' @@ -227,6 +228,8 @@ function MyContractBets(props: { const { bets, contract, metric } = props const { resolution, outcomeType } = contract + const resolutionValue = (contract as NumericContract).resolutionValue + const [collapsed, setCollapsed] = useState(true) const isBinary = outcomeType === 'BINARY' @@ -272,6 +275,7 @@ function MyContractBets(props: { Resolved{' '} @@ -430,8 +434,9 @@ export function ContractBetsTable(props: { (bet) => bet.loanAmount ?? 0 ) - const { isResolved, mechanism } = contract + const { isResolved, mechanism, outcomeType } = contract const isCPMM = mechanism === 'cpmm-1' + const isNumeric = outcomeType === 'NUMERIC' return (
@@ -461,7 +466,9 @@ export function ContractBetsTable(props: { {isCPMM && Type} Outcome Amount - {!isCPMM && {isResolved ? <>Payout : <>Sale price}} + {!isCPMM && !isNumeric && ( + {isResolved ? <>Payout : <>Sale price} + )} {!isCPMM && !isResolved && Payout if chosen} Shares Probability @@ -496,11 +503,12 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { isAnte, } = bet - const { isResolved, closeTime, mechanism } = contract + const { isResolved, closeTime, mechanism, outcomeType } = contract const isClosed = closeTime && Date.now() > closeTime const isCPMM = mechanism === 'cpmm-1' + const isNumeric = outcomeType === 'NUMERIC' const saleAmount = saleBet?.sale?.amount @@ -517,31 +525,35 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { ) const payoutIfChosenDisplay = - bet.outcome === '0' && bet.isAnte + bet.isAnte && outcomeType === 'FREE_RESPONSE' && bet.outcome === '0' ? 'N/A' : formatMoney(calculatePayout(contract, bet, bet.outcome)) return ( - {!isCPMM && !isResolved && !isClosed && !isSold && !isAnte && ( - - )} + {!isCPMM && + !isResolved && + !isClosed && + !isSold && + !isAnte && + !isNumeric && } {isCPMM && {shares >= 0 ? 'BUY' : 'SELL'}} - {outcome === '0' ? ( + {bet.isAnte ? ( 'ANTE' ) : ( )} {formatMoney(Math.abs(amount))} - {!isCPMM && {saleDisplay}} + {!isCPMM && !isNumeric && {saleDisplay}} {!isCPMM && !isResolved && {payoutIfChosenDisplay}} {formatWithCommas(Math.abs(shares))} diff --git a/web/components/bucket-input.tsx b/web/components/bucket-input.tsx new file mode 100644 index 00000000..a64ef035 --- /dev/null +++ b/web/components/bucket-input.tsx @@ -0,0 +1,43 @@ +import _ from 'lodash' +import { useState } from 'react' + +import { NumericContract } from 'common/contract' +import { getMappedBucket } from 'common/calculate-dpm' + +import { NumberInput } from './number-input' + +export function BucketInput(props: { + contract: NumericContract + isSubmitting?: boolean + onBucketChange: (value?: number, bucket?: string) => void +}) { + const { contract, isSubmitting, onBucketChange } = props + + const [numberString, setNumberString] = useState('') + + const onChange = (s: string) => { + setNumberString(s) + + const value = parseFloat(s) + + if (!isFinite(value)) { + onBucketChange(undefined, undefined) + return + } + + const bucket = getMappedBucket(value, contract) + + onBucketChange(value, bucket) + } + + return ( + + ) +} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 87186743..5e054255 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -16,6 +16,7 @@ import { FreeResponse, FreeResponseContract, FullContract, + NumericContract, } from 'common/contract' import { AnswerLabel, @@ -25,6 +26,7 @@ import { } from '../outcome-label' import { getOutcomeProbability, getTopAnswer } from 'common/calculate' import { AbbrContractDetails } from './contract-details' +import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' // Return a number from 0 to 1 for this contract // Resolved contracts are set to 1, for coloring purposes (even if NO) @@ -105,6 +107,13 @@ export function ContractCard(props: { contract={contract} /> )} + + {outcomeType === 'NUMERIC' && ( + + )} {outcomeType === 'FREE_RESPONSE' && ( @@ -214,3 +223,32 @@ export function FreeResponseResolutionOrChance(props: { ) } + +export function NumericResolutionOrExpectation(props: { + contract: NumericContract + className?: string +}) { + const { contract, className } = props + const { resolution } = contract + + const resolutionValue = + contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract) + + return ( + + {resolution ? ( + <> +
Resolved
+
{resolutionValue}
+ + ) : ( + <> +
+ {getExpectedValue(contract)} +
+
expected
+ + )} + + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 86612a67..4d1d772e 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -6,18 +6,26 @@ import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' import { Linkify } from '../linkify' import clsx from 'clsx' + import { FreeResponseResolutionOrChance, BinaryResolutionOrChance, + NumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import BetRow from '../bet-row' import { AnswersGraph } from '../answers/answers-graph' -import { DPM, FreeResponse, FullContract } from 'common/contract' +import { + DPM, + FreeResponse, + FullContract, + NumericContract, +} from 'common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { ShareMarket } from '../share-market' +import { NumericGraph } from './numeric-graph' export const ContractOverview = (props: { contract: Contract @@ -47,6 +55,13 @@ export const ContractOverview = (props: { large /> )} + + {outcomeType === 'NUMERIC' && ( + + )} {isBinary ? ( @@ -65,28 +80,33 @@ export const ContractOverview = (props: { ) )} + {outcomeType === 'NUMERIC' && ( + + + + )} + - - - {isBinary ? ( - - ) : ( + {isBinary && }{' '} + {outcomeType === 'FREE_RESPONSE' && ( } bets={bets} /> )} - + {outcomeType === 'NUMERIC' && ( + + )} {(contract.description || isCreator) && } - {isCreator && } - - {contract.outcomeType === 'FREE_RESPONSE' && ( + {outcomeType === 'FREE_RESPONSE' && (
General Comments
diff --git a/web/components/contract/numeric-graph.tsx b/web/components/contract/numeric-graph.tsx new file mode 100644 index 00000000..5a5b2aff --- /dev/null +++ b/web/components/contract/numeric-graph.tsx @@ -0,0 +1,76 @@ +import { DatumValue } from '@nivo/core' +import { ResponsiveLine } from '@nivo/line' +import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' +import _ from 'lodash' +import { memo } from 'react' +import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm' +import { NumericContract } from '../../../common/contract' +import { useWindowSize } from '../../hooks/use-window-size' + +export const NumericGraph = memo(function NumericGraph(props: { + contract: NumericContract + height?: number +}) { + const { contract, height } = props + const { totalShares, bucketCount, min, max } = contract + + const bucketProbs = getDpmOutcomeProbabilities(totalShares) + + const xs = _.range(bucketCount).map( + (i) => min + ((max - min) * i) / bucketCount + ) + const probs = _.range(bucketCount).map((i) => bucketProbs[`${i}`] * 100) + const points = probs.map((prob, i) => ({ x: xs[i], y: prob })) + const maxProb = Math.max(...probs) + const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }] + + const yTickValues = [ + 0, + 0.25 * maxProb, + 0.5 & maxProb, + 0.75 * maxProb, + maxProb, + ] + + const { width } = useWindowSize() + + const numXTickValues = !width || width < 800 ? 2 : 5 + + return ( +
= 800 ? 350 : 250) }} + > + `${Math.round(+d * 100) / 100}`} + axisBottom={{ + tickValues: numXTickValues, + format: (d) => `${Math.round(+d * 100) / 100}`, + }} + colors={{ datum: 'color' }} + pointSize={0} + enableSlices="x" + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 50 }} + /> +
+ ) +}) + +function formatPercent(y: DatumValue) { + const p = Math.round(+y * 100) / 100 + return `${p}%` +} diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 08dc0a1c..6e883677 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -86,6 +86,7 @@ export function BetStatusText(props: { of{' '} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 096579c5..198647c6 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -161,16 +161,18 @@ export function FeedComment(props: { username={userUsername} name={userName} />{' '} - {!matchedBet && userPosition > 0 && ( - <> - {'is '} - - - )} + {!matchedBet && + userPosition > 0 && + contract.outcomeType !== 'NUMERIC' && ( + <> + {'is '} + + + )} <> {bought} {money} {contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && ( @@ -179,6 +181,7 @@ export function FeedComment(props: { of{' '} @@ -314,6 +317,8 @@ export function CommentInput(props: { const shouldCollapseAfterClickOutside = false + const isNumeric = contract.outcomeType === 'NUMERIC' + return ( <> @@ -328,23 +333,28 @@ export function CommentInput(props: { contract={contract} bet={mostRecentCommentableBet} isSelf={true} - hideOutcome={contract.outcomeType === 'FREE_RESPONSE'} + hideOutcome={ + isNumeric || contract.outcomeType === 'FREE_RESPONSE' + } /> )} - {!mostRecentCommentableBet && user && userPosition > 0 && ( - <> - {"You're"} - - - )} + {!mostRecentCommentableBet && + user && + userPosition > 0 && + !isNumeric && ( + <> + {"You're"} + + + )}
diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 287e2b46..88fa8c89 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -37,6 +37,7 @@ import { TruncatedComment, } from 'web/components/feed/feed-comments' import { FeedBet, FeedBetGroup } from 'web/components/feed/feed-bets' +import { NumericContract } from 'common/contract' export function FeedItems(props: { contract: Contract @@ -215,8 +216,11 @@ function OutcomeIcon(props: { outcome?: string }) { function FeedResolve(props: { contract: Contract }) { const { contract } = props const { creatorName, creatorUsername } = contract + const resolution = contract.resolution || 'CANCEL' + const resolutionValue = (contract as NumericContract).resolutionValue + return ( <>
@@ -236,6 +240,7 @@ function FeedResolve(props: { contract: Contract }) { resolved this market to{' '} {' '} diff --git a/web/components/number-input.tsx b/web/components/number-input.tsx new file mode 100644 index 00000000..0dbc712e --- /dev/null +++ b/web/components/number-input.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx' +import _ from 'lodash' + +import { Col } from './layout/col' +import { Spacer } from './layout/spacer' + +export function NumberInput(props: { + numberString: string + onChange: (newNumberString: string) => void + error: string | undefined + label: string + disabled?: boolean + className?: string + inputClassName?: string + // Needed to focus the amount input + inputRef?: React.MutableRefObject + children?: any +}) { + const { + numberString, + onChange, + error, + label, + disabled, + className, + inputClassName, + inputRef, + children, + } = props + + return ( + + + + + + {error && ( +
+ {error} +
+ )} + + {children} + + ) +} diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx new file mode 100644 index 00000000..ba25c411 --- /dev/null +++ b/web/components/numeric-bet-panel.tsx @@ -0,0 +1,207 @@ +import clsx from 'clsx' +import { getNumericBetsInfo } from 'common/new-bet' +import { useState } from 'react' + +import { Bet } from '../../common/bet' +import { + calculatePayoutAfterCorrectBet, + getOutcomeProbability, +} from '../../common/calculate' +import { NumericContract } from '../../common/contract' +import { formatPercent, formatMoney } from '../../common/util/format' +import { useUser } from '../hooks/use-user' +import { placeBet } from '../lib/firebase/api-call' +import { firebaseLogin, User } from '../lib/firebase/users' +import { BuyAmountInput } from './amount-input' +import { BucketInput } from './bucket-input' +import { Col } from './layout/col' +import { Row } from './layout/row' +import { Spacer } from './layout/spacer' + +export function NumericBetPanel(props: { + contract: NumericContract + className?: string +}) { + const { contract, className } = props + const user = useUser() + + return ( + +
Place your bet
+ + + + {user === null && ( + + )} + + ) +} + +function NumericBuyPanel(props: { + contract: NumericContract + user: User | null | undefined + onBuySuccess?: () => void +}) { + const { contract, user, onBuySuccess } = props + + const [bucketChoice, setBucketChoice] = useState( + undefined + ) + + const [value, setValue] = useState(undefined) + + const [betAmount, setBetAmount] = useState(undefined) + + const [valueError, setValueError] = useState() + const [error, setError] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + const [wasSubmitted, setWasSubmitted] = useState(false) + + function onBetChange(newAmount: number | undefined) { + setWasSubmitted(false) + setBetAmount(newAmount) + } + + async function submitBet() { + if ( + !user || + !betAmount || + bucketChoice === undefined || + value === undefined + ) + return + + setError(undefined) + setIsSubmitting(true) + + const result = await placeBet({ + amount: betAmount, + outcome: bucketChoice, + value, + contractId: contract.id, + }).then((r) => r.data as any) + + console.log('placed bet. Result:', result) + + if (result?.status === 'success') { + setIsSubmitting(false) + setWasSubmitted(true) + setBetAmount(undefined) + if (onBuySuccess) onBuySuccess() + } else { + setError(result?.message || 'Error placing bet') + setIsSubmitting(false) + } + } + + const betDisabled = isSubmitting || !betAmount || !bucketChoice || error + + const { newBet, newPool, newTotalShares, newTotalBets } = getNumericBetsInfo( + { id: 'dummy', balance: 0 } as User, // a little hackish + value ?? 0, + bucketChoice ?? 'NaN', + betAmount ?? 0, + contract, + 'dummy id' + ) + + const { probAfter: outcomeProb, shares } = newBet + + const initialProb = bucketChoice + ? getOutcomeProbability(contract, bucketChoice) + : 0 + + const currentPayout = + betAmount && bucketChoice + ? calculatePayoutAfterCorrectBet( + { + ...contract, + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + }, + { + outcome: bucketChoice, + amount: betAmount, + shares, + } as Bet + ) + : 0 + + const currentReturn = + betAmount && bucketChoice ? (currentPayout - betAmount) / betAmount : 0 + const currentReturnPercent = formatPercent(currentReturn) + + return ( + <> +
+ Predicted value +
+ + (setValue(v), setBucketChoice(b))} + /> + +
Bet amount
+ + + + +
Probability
+ +
{formatPercent(initialProb)}
+
+
{formatPercent(outcomeProb)}
+
+
+ + + +
+ Estimated +
payout if correct +
+
+ + + {formatMoney(currentPayout)} + + (+{currentReturnPercent}) + +
+ + + + + {user && ( + + )} + + {wasSubmitted &&
Bet submitted!
} + + ) +} diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx new file mode 100644 index 00000000..cb208a9f --- /dev/null +++ b/web/components/numeric-resolution-panel.tsx @@ -0,0 +1,101 @@ +import clsx from 'clsx' +import React, { useEffect, useState } from 'react' + +import { Col } from './layout/col' +import { User } from 'web/lib/firebase/users' +import { NumberCancelSelector } from './yes-no-selector' +import { Spacer } from './layout/spacer' +import { ResolveConfirmationButton } from './confirmation-button' +import { resolveMarket } from 'web/lib/firebase/api-call' +import { NumericContract } from 'common/contract' +import { BucketInput } from './bucket-input' + +export function NumericResolutionPanel(props: { + creator: User + contract: NumericContract + className?: string +}) { + useEffect(() => { + // warm up cloud function + resolveMarket({} as any).catch() + }, []) + + const { contract, className } = props + + const [outcomeMode, setOutcomeMode] = useState< + 'NUMBER' | 'CANCEL' | undefined + >() + const [outcome, setOutcome] = useState() + const [value, setValue] = useState() + + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(undefined) + + const resolve = async () => { + const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL' + if (outcomeMode === undefined || finalOutcome === undefined) return + + setIsSubmitting(true) + + const result = await resolveMarket({ + outcome: finalOutcome, + value, + contractId: contract.id, + }).then((r) => r.data) + + console.log('resolved', outcome, 'result:', result) + + if (result?.status !== 'success') { + setError(result?.message || 'Error resolving market') + } + setIsSubmitting(false) + } + + const submitButtonClass = + outcomeMode === 'CANCEL' + ? 'bg-yellow-400 hover:bg-yellow-500' + : outcome !== undefined + ? 'btn-primary' + : 'btn-disabled' + + return ( + +
Resolve market
+ +
Outcome
+ + + + + + + + {outcomeMode === 'NUMBER' && ( + (setValue(v), setOutcome(o))} + /> + )} + +
+ {outcome === 'CANCEL' ? ( + <>All trades will be returned with no fees. + ) : ( + <>Resolving this market will immediately pay out traders. + )} +
+ + + + {!!error &&
{error}
} + + + + ) +} diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 5ae08135..13152e4b 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx' import { Answer } from 'common/answer' import { getProbability } from 'common/calculate' +import { getValueFromBucket } from 'common/calculate-dpm' import { Binary, Contract, @@ -9,6 +10,7 @@ import { FreeResponse, FreeResponseContract, FullContract, + NumericContract, } from 'common/contract' import { formatPercent } from 'common/util/format' import { ClientRender } from './client-render' @@ -17,12 +19,20 @@ export function OutcomeLabel(props: { contract: Contract outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string truncate: 'short' | 'long' | 'none' + value?: number }) { - const { outcome, contract, truncate } = props + const { outcome, contract, truncate, value } = props if (contract.outcomeType === 'BINARY') return + if (contract.outcomeType === 'NUMERIC') + return ( + + {value ?? getValueFromBucket(outcome, contract as NumericContract)} + + ) + return ( } diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index faa8296e..38723060 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -195,6 +195,37 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) { ) } +export function NumberCancelSelector(props: { + selected: 'NUMBER' | 'CANCEL' | undefined + onSelect: (selected: 'NUMBER' | 'CANCEL') => void + className?: string + btnClassName?: string +}) { + const { selected, onSelect, className } = props + + const btnClassName = clsx('px-6 flex-1', props.btnClassName) + + return ( + + + + + + ) +} + function Button(props: { className?: string onClick?: () => void diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index fa786952..2bd23e39 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -43,6 +43,7 @@ export const createAnswer = cloudFunction< export const resolveMarket = cloudFunction< { outcome: string + value?: number contractId: string probabilityInt?: number resolutions?: { [outcome: string]: number } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 97e6b316..efe0e149 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' +import _ from 'lodash' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' @@ -24,16 +25,23 @@ import Custom404 from '../404' import { AnswersPanel } from 'web/components/answers/answers-panel' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Leaderboard } from 'web/components/leaderboard' -import _ from 'lodash' import { resolvedPayout } from 'common/calculate' import { formatMoney } from 'common/util/format' import { useUserById } from 'web/hooks/use-users' import { ContractTabs } from 'web/components/contract/contract-tabs' import { FirstArgument } from 'common/util/types' -import { DPM, FreeResponse, FullContract } from 'common/contract' +import { + BinaryContract, + DPM, + FreeResponse, + FullContract, + NumericContract, +} from 'common/contract' import { contractTextDetails } from 'web/components/contract/contract-details' import { useWindowSize } from 'web/hooks/use-window-size' import Confetti from 'react-confetti' +import { NumericBetPanel } from '../../components/numeric-bet-panel' +import { NumericResolutionPanel } from '../../components/numeric-resolution-panel' import { FeedComment } from 'web/components/feed/feed-comments' import { FeedBet } from 'web/components/feed/feed-bets' @@ -113,22 +121,40 @@ export function ContractPageContent(props: FirstArgument) { return } - const { creatorId, isResolved, question, outcomeType, resolution } = contract + const { creatorId, isResolved, question, outcomeType } = contract const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user - const hasSidePanel = isBinary && (allowTrade || allowResolve) + const hasSidePanel = (isBinary || isNumeric) && (allowTrade || allowResolve) const ogCardProps = getOpenGraphProps(contract) const rightSidebar = hasSidePanel ? ( - {allowTrade && ( - - )} - {allowResolve && } + {allowTrade && + (isNumeric ? ( + + ) : ( + + ))} + {allowResolve && + (isNumeric ? ( + + ) : ( + + ))} ) : null @@ -179,6 +205,13 @@ export function ContractPageContent(props: FirstArgument) { )} + {isNumeric && ( + + )} + {isResolved && ( <>
diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 3c1a17a8..fec37708 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -66,6 +66,8 @@ export function NewContract(props: { question: string; tag?: string }) { const [outcomeType, setOutcomeType] = useState('BINARY') const [initialProb, setInitialProb] = useState(50) + const [minString, setMinString] = useState('') + const [maxString, setMaxString] = useState('') const [description, setDescription] = useState('') const [category, setCategory] = useState('') @@ -94,6 +96,9 @@ export function NewContract(props: { question: string; tag?: string }) { const balance = creator?.balance || 0 + const min = minString ? parseFloat(minString) : undefined + const max = maxString ? parseFloat(maxString) : undefined + const isValid = initialProb > 0 && initialProb < 100 && @@ -104,7 +109,14 @@ export function NewContract(props: { question: string; tag?: string }) { (ante <= balance || deservesDailyFreeMarket) && // closeTime must be in the future closeTime && - closeTime > Date.now() + closeTime > Date.now() && + (outcomeType !== 'NUMERIC' || + (min !== undefined && + max !== undefined && + isFinite(min) && + isFinite(max) && + min < max && + max - min > 0.01)) async function submit() { // TODO: Tell users why their contract is invalid @@ -121,6 +133,8 @@ export function NewContract(props: { question: string; tag?: string }) { ante, closeTime, tags: category ? [category] : undefined, + min, + max, }) ).then((r) => r.data || {}) @@ -168,6 +182,17 @@ export function NewContract(props: { question: string; tag?: string }) { /> Free response + @@ -184,6 +209,40 @@ export function NewContract(props: { question: string; tag?: string }) {
)} + {outcomeType === 'NUMERIC' && ( +
+ + + + e.stopPropagation()} + onChange={(e) => setMinString(e.target.value)} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={minString ?? ''} + /> + e.stopPropagation()} + onChange={(e) => setMaxString(e.target.value)} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={maxString} + /> + +
+ )} +
diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index a1d64c36..9cffb8d4 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -5,6 +5,7 @@ import { DPM, FreeResponse, FullContract, + NumericContract, } from 'common/contract' import { DOMAIN } from 'common/envs/constants' import { AnswersGraph } from 'web/components/answers/answers-graph' @@ -12,6 +13,7 @@ import BetRow from 'web/components/bet-row' import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, + NumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { ContractDetails } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' @@ -129,6 +131,12 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { truncate="long" /> )} + + {outcomeType === 'NUMERIC' && resolution && ( + + )} diff --git a/web/pages/home.tsx b/web/pages/home.tsx index b3fef139..912bb5d4 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,5 +1,6 @@ -import React from 'react' +import React, { useState } from 'react' import Router from 'next/router' + import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user'