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 <sgrugett@gmail.com>
This commit is contained in:
James Grugett 2022-05-19 12:42:03 -05:00 committed by GitHub
parent 7d8ccb78a4
commit 76f27d1a93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1261 additions and 129 deletions

View File

@ -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<DPM, Numeric>,
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
}

View File

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

View File

@ -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<DPM, any>,
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<DPM, any>, bet: Bet) {
function calculateMktDpmPayout(
contract: FullContract<DPM, Binary | FreeResponse | Numeric>,
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<DPM, any>, 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)
}

View File

@ -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: '',
}
}

View File

@ -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<DPM | CPMM, Binary | Multi | FreeResponse>
export type Contract = FullContract<
DPM | CPMM,
Binary | Multi | FreeResponse | Numeric
>
export type BinaryContract = FullContract<DPM | CPMM, Binary>
export type FreeResponseContract = FullContract<DPM | CPMM, FreeResponse>
export type NumericContract = FullContract<DPM, Numeric>
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

View File

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

View File

@ -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[],

View File

@ -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'

View File

@ -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<DPM, any>,
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<Fees>(
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<DPM, any>,
bets: Bet[],

View File

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

View File

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

View File

@ -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<Answer>(
firestore
.collection(`contracts/${contractId}/answers`)

View File

@ -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<DPM, FreeResponse>,
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<DPM, Numeric>,
ante,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
}

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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{' '}
<OutcomeLabel
outcome={resolution}
value={resolutionValue}
contract={contract}
truncate="short"
/>
@ -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 (
<div className={clsx('overflow-x-auto', className)}>
@ -461,7 +466,9 @@ export function ContractBetsTable(props: {
{isCPMM && <th>Type</th>}
<th>Outcome</th>
<th>Amount</th>
{!isCPMM && <th>{isResolved ? <>Payout</> : <>Sale price</>}</th>}
{!isCPMM && !isNumeric && (
<th>{isResolved ? <>Payout</> : <>Sale price</>}</th>
)}
{!isCPMM && !isResolved && <th>Payout if chosen</th>}
<th>Shares</th>
<th>Probability</th>
@ -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 (
<tr>
<td className="text-neutral">
{!isCPMM && !isResolved && !isClosed && !isSold && !isAnte && (
<SellButton contract={contract} bet={bet} />
)}
{!isCPMM &&
!isResolved &&
!isClosed &&
!isSold &&
!isAnte &&
!isNumeric && <SellButton contract={contract} bet={bet} />}
</td>
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
<td>
{outcome === '0' ? (
{bet.isAnte ? (
'ANTE'
) : (
<OutcomeLabel
outcome={outcome}
value={(bet as any).value}
contract={contract}
truncate="short"
/>
)}
</td>
<td>{formatMoney(Math.abs(amount))}</td>
{!isCPMM && <td>{saleDisplay}</td>}
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
<td>{formatWithCommas(Math.abs(shares))}</td>
<td>

View File

@ -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 (
<NumberInput
inputClassName="w-full max-w-none"
onChange={onChange}
error={undefined}
disabled={isSubmitting}
numberString={numberString}
label="Value"
/>
)
}

View File

@ -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' && (
<NumericResolutionOrExpectation
className="items-center"
contract={contract as NumericContract}
/>
)}
</Row>
{outcomeType === 'FREE_RESPONSE' && (
@ -214,3 +223,32 @@ export function FreeResponseResolutionOrChance(props: {
</Col>
)
}
export function NumericResolutionOrExpectation(props: {
contract: NumericContract
className?: string
}) {
const { contract, className } = props
const { resolution } = contract
const resolutionValue =
contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract)
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
<div className="text-blue-400">{resolutionValue}</div>
</>
) : (
<>
<div className="text-3xl text-blue-400">
{getExpectedValue(contract)}
</div>
<div className="text-base text-blue-400">expected</div>
</>
)}
</Col>
)
}

View File

@ -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' && (
<NumericResolutionOrExpectation
contract={contract as NumericContract}
className="hidden items-end xl:flex"
/>
)}
</Row>
{isBinary ? (
@ -65,28 +80,33 @@ export const ContractOverview = (props: {
)
)}
{outcomeType === 'NUMERIC' && (
<Row className="items-center justify-between gap-4 xl:hidden">
<NumericResolutionOrExpectation
contract={contract as NumericContract}
/>
</Row>
)}
<ContractDetails
contract={contract}
bets={bets}
isCreator={isCreator}
/>
</Col>
<Spacer h={4} />
{isBinary ? (
<ContractProbGraph contract={contract} bets={bets} />
) : (
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '}
{outcomeType === 'FREE_RESPONSE' && (
<AnswersGraph
contract={contract as FullContract<DPM, FreeResponse>}
bets={bets}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericGraph contract={contract as NumericContract} />
)}
{(contract.description || isCreator) && <Spacer h={6} />}
{isCreator && <ShareMarket className="px-2" contract={contract} />}
<ContractDescription
className="px-2"
contract={contract}

View File

@ -16,6 +16,7 @@ export function ContractTabs(props: {
comments: Comment[]
}) {
const { contract, user, comments } = props
const { outcomeType } = contract
const bets = useBets(contract.id) ?? props.bets
// Decending creation time.
@ -47,7 +48,7 @@ export function ContractTabs(props: {
}
betRowClassName="!mt-0 xl:hidden"
/>
{contract.outcomeType === 'FREE_RESPONSE' && (
{outcomeType === 'FREE_RESPONSE' && (
<Col className={'mt-8 flex w-full '}>
<div className={'text-md mt-8 mb-2 text-left'}>General Comments</div>
<div className={'mb-4 w-full border-b border-gray-200'} />

View File

@ -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 (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: maxProb, type: 'linear' }}
yFormat={formatPercent}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'linear',
min: min,
max: max,
}}
xFormat={(d) => `${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 }}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
const p = Math.round(+y * 100) / 100
return `${p}%`
}

View File

@ -86,6 +86,7 @@ export function BetStatusText(props: {
of{' '}
<OutcomeLabel
outcome={outcome}
value={(bet as any).value}
contract={contract}
truncate="short"
/>

View File

@ -161,7 +161,9 @@ export function FeedComment(props: {
username={userUsername}
name={userName}
/>{' '}
{!matchedBet && userPosition > 0 && (
{!matchedBet &&
userPosition > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
{'is '}
<CommentStatus
@ -179,6 +181,7 @@ export function FeedComment(props: {
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract}
truncate="short"
/>
@ -314,6 +317,8 @@ export function CommentInput(props: {
const shouldCollapseAfterClickOutside = false
const isNumeric = contract.outcomeType === 'NUMERIC'
return (
<>
<Row className={'mb-2 flex w-full gap-2'}>
@ -328,10 +333,15 @@ 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 && (
{!mostRecentCommentableBet &&
user &&
userPosition > 0 &&
!isNumeric && (
<>
{"You're"}
<CommentStatus

View File

@ -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 (
<>
<div>
@ -236,6 +240,7 @@ function FeedResolve(props: { contract: Contract }) {
resolved this market to{' '}
<OutcomeLabel
outcome={resolution}
value={resolutionValue}
contract={contract}
truncate="long"
/>{' '}

View File

@ -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<any>
children?: any
}) {
const {
numberString,
onChange,
error,
label,
disabled,
className,
inputClassName,
inputRef,
children,
} = props
return (
<Col className={className}>
<label className="input-group">
<span className="bg-gray-200 text-sm">{label}</span>
<input
className={clsx(
'input input-bordered max-w-[200px] text-lg',
error && 'input-error',
inputClassName
)}
ref={inputRef}
type="number"
placeholder="0"
maxLength={9}
value={numberString}
disabled={disabled}
onChange={(e) => onChange(e.target.value.substring(0, 9))}
/>
</label>
<Spacer h={4} />
{error && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error}
</div>
)}
{children}
</Col>
)
}

View File

@ -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 (
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
<div className="mb-6 text-2xl">Place your bet</div>
<NumericBuyPanel contract={contract} user={user} />
{user === null && (
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up to trade!
</button>
)}
</Col>
)
}
function NumericBuyPanel(props: {
contract: NumericContract
user: User | null | undefined
onBuySuccess?: () => void
}) {
const { contract, user, onBuySuccess } = props
const [bucketChoice, setBucketChoice] = useState<string | undefined>(
undefined
)
const [value, setValue] = useState<number | undefined>(undefined)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [valueError, setValueError] = useState<string | undefined>()
const [error, setError] = useState<string | undefined>()
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 (
<>
<div className="my-3 text-left text-sm text-gray-500">
Predicted value
</div>
<BucketInput
contract={contract}
isSubmitting={isSubmitting}
onBucketChange={(v, b) => (setValue(v), setBucketChoice(b))}
/>
<div className="my-3 text-left text-sm text-gray-500">Bet amount</div>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
onChange={onBetChange}
error={error}
setError={setError}
disabled={isSubmitting}
/>
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div>
<Row>
<div>{formatPercent(initialProb)}</div>
<div className="mx-2"></div>
<div>{formatPercent(outcomeProb)}</div>
</Row>
</Row>
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
Estimated
<br /> payout if correct
</div>
</Row>
<Row className="flex-wrap items-end justify-end gap-2">
<span className="whitespace-nowrap">
{formatMoney(currentPayout)}
</span>
<span>(+{currentReturnPercent})</span>
</Row>
</Row>
</Col>
<Spacer h={8} />
{user && (
<button
className={clsx(
'btn flex-1',
betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
</button>
)}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
</>
)
}

View File

@ -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<string | undefined>()
const [value, setValue] = useState<number | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(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 (
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
<div className="mb-3 text-sm text-gray-500">Outcome</div>
<Spacer h={4} />
<NumberCancelSelector selected={outcomeMode} onSelect={setOutcomeMode} />
<Spacer h={4} />
{outcomeMode === 'NUMBER' && (
<BucketInput
contract={contract}
isSubmitting={isSubmitting}
onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
/>
)}
<div>
{outcome === 'CANCEL' ? (
<>All trades will be returned with no fees.</>
) : (
<>Resolving this market will immediately pay out traders.</>
)}
</div>
<Spacer h={4} />
{!!error && <div className="text-red-500">{error}</div>}
<ResolveConfirmationButton
onResolve={resolve}
isSubmitting={isSubmitting}
openModalButtonClass={clsx('w-full mt-2', submitButtonClass)}
submitButtonClass={submitButtonClass}
/>
</Col>
)
}

View File

@ -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 <BinaryOutcomeLabel outcome={outcome as any} />
if (contract.outcomeType === 'NUMERIC')
return (
<span className="text-blue-500">
{value ?? getValueFromBucket(outcome, contract as NumericContract)}
</span>
)
return (
<FreeResponseOutcomeLabel
contract={contract as FullContract<DPM, FreeResponse>}

View File

@ -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 (
<Col className={clsx('gap-2', className)}>
<Button
color={selected === 'NUMBER' ? 'green' : 'gray'}
onClick={() => onSelect('NUMBER')}
className={clsx('whitespace-nowrap', btnClassName)}
>
Choose value
</Button>
<Button
color={selected === 'CANCEL' ? 'yellow' : 'gray'}
onClick={() => onSelect('CANCEL')}
className={clsx(btnClassName, '')}
>
N/A
</Button>
</Col>
)
}
function Button(props: {
className?: string
onClick?: () => void

View File

@ -43,6 +43,7 @@ export const createAnswer = cloudFunction<
export const resolveMarket = cloudFunction<
{
outcome: string
value?: number
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }

View File

@ -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<typeof ContractPage>) {
return <Custom404 />
}
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 ? (
<Col className="gap-4">
{allowTrade && (
{allowTrade &&
(isNumeric ? (
<NumericBetPanel
className="hidden xl:flex"
contract={contract as NumericContract}
/>
) : (
<BetPanel className="hidden xl:flex" contract={contract} />
)}
{allowResolve && <ResolutionPanel creator={user} contract={contract} />}
))}
{allowResolve &&
(isNumeric ? (
<NumericResolutionPanel
creator={user}
contract={contract as NumericContract}
/>
) : (
<ResolutionPanel
creator={user}
contract={contract as BinaryContract}
/>
))}
</Col>
) : null
@ -179,6 +205,13 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
</>
)}
{isNumeric && (
<NumericBetPanel
className="sm:hidden"
contract={contract as NumericContract}
/>
)}
{isResolved && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2">

View File

@ -66,6 +66,8 @@ export function NewContract(props: { question: string; tag?: string }) {
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
const [initialProb, setInitialProb] = useState(50)
const [minString, setMinString] = useState('')
const [maxString, setMaxString] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<string>('')
@ -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 }) {
/>
<span className="label-text">Free response</span>
</label>
<label className="label cursor-pointer gap-2">
<input
className="radio"
type="radio"
name="opt"
checked={outcomeType === 'NUMERIC'}
value="NUMERIC"
onChange={() => setOutcomeType('NUMERIC')}
/>
<span className="label-text">Numeric (experimental)</span>
</label>
</Row>
<Spacer h={4} />
@ -184,6 +209,40 @@ export function NewContract(props: { question: string; tag?: string }) {
</div>
)}
{outcomeType === 'NUMERIC' && (
<div className="form-control items-start">
<label className="label gap-2">
<span className="mb-1">Range</span>
<InfoTooltip text="The minimum and maximum numbers across the numeric range." />
</label>
<Row className="gap-2">
<input
type="number"
className="input input-bordered"
placeholder="MIN"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)}
min={Number.MIN_SAFE_INTEGER}
max={Number.MAX_SAFE_INTEGER}
disabled={isSubmitting}
value={minString ?? ''}
/>
<input
type="number"
className="input input-bordered"
placeholder="MAX"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMaxString(e.target.value)}
min={Number.MIN_SAFE_INTEGER}
max={Number.MAX_SAFE_INTEGER}
disabled={isSubmitting}
value={maxString}
/>
</Row>
</div>
)}
<Spacer h={4} />
<div className="form-control mb-1 items-start">

View File

@ -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 && (
<NumericResolutionOrExpectation
contract={contract as NumericContract}
/>
)}
</Row>
<Spacer h={2} />

View File

@ -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'