diff --git a/common/calculate.ts b/common/calculate.ts index a0574c10..482a0ccf 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -18,15 +18,24 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, +} from './contract' -export function getProbability(contract: BinaryContract) { +export function getProbability( + contract: BinaryContract | PseudoNumericContract +) { return contract.mechanism === 'cpmm-1' ? getCpmmProbability(contract.pool, contract.p) : getDpmProbability(contract.totalShares) } -export function getInitialProbability(contract: BinaryContract) { +export function getInitialProbability( + contract: BinaryContract | PseudoNumericContract +) { if (contract.initialProbability) return contract.initialProbability if (contract.mechanism === 'dpm-2' || (contract as any).totalShares) @@ -65,7 +74,9 @@ export function calculateShares( } export function calculateSaleAmount(contract: Contract, bet: Bet) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -87,7 +98,9 @@ export function getProbabilityAfterSale( } export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) { const outcome = contract.resolution if (!outcome) throw new Error('Contract not resolved') - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some( - (shares) => shares > 0 - ) + const hasShares = Object.values(totalShares).some((shares) => shares > 0) return { invested: Math.max(0, currentInvested), diff --git a/common/contract.ts b/common/contract.ts index dc91a20e..3a90d01f 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -2,9 +2,10 @@ import { Answer } from './answer' import { Fees } from './fees' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | FreeResponse | Numeric +export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyContractType = | (CPMM & Binary) + | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) @@ -33,7 +34,7 @@ export type Contract = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market resolution?: string - resolutionProbability?: number, + resolutionProbability?: number closeEmailsSent?: number @@ -44,7 +45,8 @@ export type Contract = { collectedFees: Fees } & T -export type BinaryContract = Contract & Binary +export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type DPMContract = Contract & DPM @@ -75,6 +77,18 @@ export type Binary = { resolution?: resolution } +export type PseudoNumeric = { + outcomeType: 'PSEUDO_NUMERIC' + min: number + max: number + isLogScale: boolean + resolutionValue?: number + + // same as binary market; map everything to probability + initialProbability: number + resolutionProbability?: number +} + export type FreeResponse = { outcomeType: 'FREE_RESPONSE' answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. @@ -94,7 +108,7 @@ export type Numeric = { export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const -export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const +export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/new-bet.ts b/common/new-bet.ts index ba799624..236c0908 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -14,6 +14,7 @@ import { DPMBinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from './contract' import { noFees } from './fees' import { addObjects } from './util/object' @@ -32,7 +33,7 @@ export type BetInfo = { export const getNewBinaryCpmmBetInfo = ( outcome: 'YES' | 'NO', amount: number, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..6c89c8c4 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -7,6 +7,7 @@ import { FreeResponse, Numeric, outcomeType, + PseudoNumeric, } from './contract' import { User } from './user' import { parseTags } from './util/parse' @@ -27,7 +28,8 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -37,6 +39,8 @@ export function getNewContract( const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) + : outcomeType === 'PSEUDO_NUMERIC' + ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) : getFreeAnswerProps(ante) @@ -111,6 +115,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { return system } +const getPseudoNumericCpmmProps = ( + initialProb: number, + ante: number, + min: number, + max: number, + isLogScale: boolean +) => { + const system: CPMM & PseudoNumeric = { + ...getBinaryCpmmProps(initialProb, ante), + outcomeType: 'PSEUDO_NUMERIC', + min, + max, + isLogScale, + } + + return system +} + const getFreeAnswerProps = (ante: number) => { const system: DPM & FreeResponse = { mechanism: 'dpm-2', diff --git a/common/payouts.ts b/common/payouts.ts index f2c8d271..1469cf4e 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,7 +1,12 @@ import { sumBy, groupBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' -import { Contract, CPMMBinaryContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + DPMContract, + PseudoNumericContract, +} from './contract' import { Fees } from './fees' import { LiquidityProvision } from './liquidity-provision' import { @@ -56,7 +61,11 @@ export const getPayouts = ( }, resolutionProbability?: number ): PayoutInfo => { - if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { + if ( + contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') + ) { return getFixedPayouts( outcome, contract, @@ -76,7 +85,7 @@ export const getPayouts = ( export const getFixedPayouts = ( outcome: string | undefined, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, bets: Bet[], liquidities: LiquidityProvision[], resolutionProbability?: number diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts new file mode 100644 index 00000000..9a322e35 --- /dev/null +++ b/common/pseudo-numeric.ts @@ -0,0 +1,45 @@ +import { BinaryContract, PseudoNumericContract } from './contract' +import { formatLargeNumber, formatPercent } from './util/format' + +export function formatNumericProbability( + p: number, + contract: PseudoNumericContract +) { + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) +} + +export const getMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return p + + const { min, max, isLogScale } = contract + + if (isLogScale) { + const logValue = p * Math.log10(max - min) + return 10 ** logValue + min + } + + return p * (max - min) + min + } + +export const getFormattedMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return formatPercent(p) + + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) + } + +export const getPseudoProbability = ( + value: number, + min: number, + max: number, + isLogScale = false +) => { + if (isLogScale) { + return Math.log10(value - min) / Math.log10(max - min) + } + + return (value - min) / (max - min) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index c9468fdc..0d78ab5c 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -28,6 +28,7 @@ import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' +import { getPseudoProbability } from '../../common/pseudo-numeric' const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), @@ -45,19 +46,31 @@ const binarySchema = z.object({ initialProb: z.number().min(1).max(99), }) +const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) + const numericSchema = z.object({ - min: z.number(), - max: z.number(), + min: finite(), + max: finite(), + initialValue: finite(), + isLogScale: z.boolean().optional(), }) export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb - if (outcomeType === 'NUMERIC') { - ;({ min, max } = validate(numericSchema, req.body)) - if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') + let min, max, initialProb, isLogScale + + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate( + numericSchema, + req.body + )) + if (max - min <= 0.01 || initialValue < min || initialValue > max) + throw new APIError(400, 'Invalid range.') + + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 } if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) @@ -121,7 +134,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + isLogScale ?? false ) if (ante) await chargeUser(user.id, ante, true) @@ -130,7 +144,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const providerId = user.id - if (outcomeType === 'BINARY') { + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) .doc() diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1ba8ca96..40e8900c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -6,8 +6,13 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatMoney, formatPercent } from '../../common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from '../../common/util/format' import { getValueFromBucket } from '../../common/calculate-dpm' +import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -101,6 +106,17 @@ const toDisplayResolution = ( return display || resolution } + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + const { resolutionValue } = contract + + return resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? getProbability(contract), + contract + ) + } + if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 06d27668..b6c7d267 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -70,7 +70,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { const { outcome } = validate(binarySchema, req.body) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { + } else if ( + (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + mechanism == 'cpmm-1' + ) { const { outcome } = validate(binarySchema, req.body) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index bdd3ab94..67922a65 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -16,7 +16,11 @@ export const redeemShares = async (userId: string, contractId: string) => { return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') + const { mechanism, outcomeType } = contract + if ( + !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || + mechanism !== 'cpmm-1' + ) return { status: 'success' } const betsSnap = await transaction.get( diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index ee78dfec..f8976cb3 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -27,7 +27,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(RESOLUTIONS), - probabilityInt: z.number().gte(0).lt(100).optional(), + probabilityInt: z.number().gte(0).lte(100).optional(), }) const freeResponseSchema = z.union([ @@ -39,7 +39,7 @@ const freeResponseSchema = z.union([ resolutions: z.array( z.object({ answer: z.number().int().nonnegative(), - pct: z.number().gte(0).lt(100), + pct: z.number().gte(0).lte(100), }) ), }), @@ -53,7 +53,19 @@ const numericSchema = z.object({ value: z.number().optional(), }) +const pseudoNumericSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), + }), +]) + const opts = { secrets: ['MAILGUN_KEY'] } + export const resolvemarket = newEndpoint(opts, async (req, auth) => { const { contractId } = validate(bodySchema, req.body) const userId = auth.uid @@ -221,12 +233,18 @@ const sendResolutionEmails = async ( function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract + if (outcomeType === 'NUMERIC') { return { ...validate(numericSchema, body), resolutions: undefined, probabilityInt: undefined, } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } } else if (outcomeType === 'FREE_RESPONSE') { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 73055872..f76117b9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' -import { BinaryContract, CPMMBinaryContract } from 'common/contract' +import { + BinaryContract, + CPMMBinaryContract, + PseudoNumericContract, +} from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -35,6 +39,7 @@ import { getCpmmProbability, getCpmmLiquidityFee, } from 'common/calculate-cpmm' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveShares } from './use-save-shares' import { SignUpPrompt } from './sign-up-prompt' @@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device' import { track } from 'web/lib/service/analytics' export function BetPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props @@ -81,7 +86,7 @@ export function BetPanel(props: { } export function BetPanelSwitcher(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' @@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: { }) { const { contract, className, title, selected, onBetSuccess } = props - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) @@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: {
You have {formatWithCommas(floorShares)}{' '} - shares + {isPseudoNumeric ? ( + + ) : ( + + )}{' '} + shares
{tradeType === 'BUY' && ( @@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: { } function BuyPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { const { contract, user, selected, onBuySuccess } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState(undefined) @@ -302,6 +314,9 @@ function BuyPanel(props: { : 0) )} ${betChoice ?? 'YES'} shares` : undefined + + const format = getFormattedMappedValue(contract) + return ( <> onBetChoice(choice)} + isPseudoNumeric={isPseudoNumeric} />
Amount
-
Probability
+
+ {isPseudoNumeric ? 'Estimated value' : 'Probability'} +
- {formatPercent(initialProb)} + {format(initialProb)} - {formatPercent(resultProb)} + {format(resultProb)}
@@ -340,6 +358,8 @@ function BuyPanel(props: {
payout if{' '} + ) : isPseudoNumeric ? ( + 'Max payout' ) : ( <> Payout if @@ -389,7 +409,7 @@ function BuyPanel(props: { } export function SellPanel(props: { - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' @@ -488,6 +508,10 @@ export function SellPanel(props: { } } + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const format = getFormattedMappedValue(contract) + return ( <> {formatMoney(saleValue)}
-
Probability
+
+ {isPseudoNumeric ? 'Estimated value' : 'Probability'} +
- {formatPercent(initialProb)} + {format(initialProb)} - {formatPercent(resultProb)} + {format(resultProb)}
diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 9621f7a9..ae5e0b00 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' import { BetPanelSwitcher } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' @@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -32,6 +32,7 @@ export default function BetRow(props: { return ( <> { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index f41f89b6..b8fb7d31 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -40,6 +41,7 @@ import { import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' +import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { SellSharesModal } from './sell-modal' @@ -366,6 +368,7 @@ export function BetsSummary(props: { const { contract, isYourBets, className } = props const { resolution, closeTime, outcomeType, mechanism } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isCpmm = mechanism === 'cpmm-1' const isClosed = closeTime && Date.now() > closeTime @@ -427,6 +430,25 @@ export function BetsSummary(props: { + ) : isPseudoNumeric ? ( + <> + +
+ Payout if {'>='} {formatLargeNumber(contract.max)} +
+
+ {formatMoney(yesWinnings)} +
+ + +
+ Payout if {'<='} {formatLargeNumber(contract.min)} +
+
+ {formatMoney(noWinnings)} +
+ + ) : (
@@ -507,13 +529,15 @@ export function ContractBetsTable(props: { const { isResolved, mechanism, outcomeType } = contract const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return (
{amountRedeemed > 0 && ( <>
- {amountRedeemed} YES shares and {amountRedeemed} NO shares + {amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '} + {amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares automatically redeemed for {formatMoney(amountRedeemed)}.
@@ -541,7 +565,7 @@ export function ContractBetsTable(props: { )} {!isCPMM && !isResolved && Payout if chosen} Shares - Probability + {!isPseudoNumeric && Probability} Date @@ -585,6 +609,7 @@ function BetRow(props: { const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const saleAmount = saleBet?.sale?.amount @@ -628,14 +653,18 @@ function BetRow(props: { truncate="short" /> )} + {isPseudoNumeric && + ' than ' + formatNumericProbability(bet.probAfter, contract)} {formatMoney(Math.abs(amount))} {!isCPMM && !isNumeric && {saleDisplay}} {!isCPMM && !isResolved && {payoutIfChosenDisplay}} {formatWithCommas(Math.abs(shares))} - - {formatPercent(probBefore)} → {formatPercent(probAfter)} - + {!isPseudoNumeric && ( + + {formatPercent(probBefore)} → {formatPercent(probAfter)} + + )} {dayjs(createdTime).format('MMM D, h:mma')} ) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 87239465..c6cda43c 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -9,6 +9,7 @@ import { BinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from 'common/contract' import { AnswerLabel, @@ -16,7 +17,11 @@ import { CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' -import { getOutcomeProbability, getTopAnswer } from 'common/calculate' +import { + getOutcomeProbability, + getProbability, + getTopAnswer, +} from 'common/calculate' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' @@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' export function ContractCard(props: { contract: Contract @@ -131,6 +137,13 @@ export function ContractCard(props: { /> )} + {outcomeType === 'PSEUDO_NUMERIC' && ( + + )} + {outcomeType === 'NUMERIC' && ( ) : ( -
{resolutionValue}
+
+ {formatLargeNumber(resolutionValue)} +
)} ) : ( @@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: { ) } + +export function PseudoNumericResolutionOrExpectation(props: { + contract: PseudoNumericContract + className?: string +}) { + const { contract, className } = props + const { resolution, resolutionValue, resolutionProbability } = contract + const textColor = `text-blue-400` + + return ( + + {resolution ? ( + <> +
Resolved
+ + {resolution === 'CANCEL' ? ( + + ) : ( +
+ {resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? 0, + contract + )} +
+ )} + + ) : ( + <> +
+ {formatNumericProbability(getProbability(contract), contract)} +
+
expected
+ + )} + + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a68f37be..897bef04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -11,6 +11,7 @@ import { FreeResponseResolutionOrChance, BinaryResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' import BetRow from '../bet-row' @@ -32,6 +33,7 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( @@ -49,6 +51,13 @@ export const ContractOverview = (props: { /> )} + {isPseudoNumeric && ( + + )} + {outcomeType === 'NUMERIC' && ( + {tradingAllowed(contract) && } + + ) : isPseudoNumeric ? ( + + {tradingAllowed(contract) && } ) : ( @@ -86,7 +100,9 @@ export const ContractOverview = (props: { /> - {isBinary && }{' '} + {(isBinary || isPseudoNumeric) && ( + + )}{' '} {outcomeType === 'FREE_RESPONSE' && ( )} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 7386d748..a9d26e2e 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -5,16 +5,20 @@ import dayjs from 'dayjs' import { memo } from 'react' import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' +import { getMappedValue } from 'common/pseudo-numeric' +import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract bets: Bet[] height?: number }) { const { contract, height } = props - const { resolutionTime, closeTime } = contract + const { resolutionTime, closeTime, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) @@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { contract.createdTime, ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const probs = [startProb, ...bets.map((bet) => bet.probAfter)] + + const f = getMappedValue(contract) + + const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const isClosed = !!closeTime && Date.now() > closeTime const latestTime = dayjs( @@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { times.push(latestTime.toDate()) probs.push(probs[probs.length - 1]) - const yTickValues = [0, 25, 50, 75, 100] + const quartiles = [0, 25, 50, 75, 100] + + const yTickValues = isBinary + ? quartiles + : quartiles.map((x) => x / 100).map(f) const { width } = useWindowSize() @@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints + const points: { x: Date; y: number }[] = [] + const s = isBinary ? 100 : 1 + const c = isLogScale && contract.min === 0 ? 1 : 0 + for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: probs[i] * 100 } + points[points.length] = { x: times[i], y: s * probs[i] + c } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: probs[i] * 100, + y: s * probs[i] + c, } } } } - const data = [{ id: 'Yes', data: points, color: '#11b981' }] + const data = [ + { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, + ] const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) + const formatter = isBinary + ? formatPercent + : (x: DatumValue) => formatLargeNumber(+x.valueOf()) + return (
) : ( )} @@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { {/* Down bet triangle */} - {contract.outcomeType !== 'BINARY' ? ( + {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
= 0 ? 'bought' : 'sold' @@ -97,7 +100,10 @@ export function BetStatusText(props: { value={(bet as any).value} contract={contract} truncate="short" - /> + />{' '} + {isPseudoNumeric + ? ' than ' + formatNumericProbability(bet.probAfter, contract) + : ' at ' + formatPercent(bet.probAfter)} )} diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index ebac68e5..cf111281 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users' import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' +import { NumericContract, PseudoNumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api-call' -import { NumericContract } from 'common/contract' import { BucketInput } from './bucket-input' +import { getPseudoProbability } from 'common/pseudo-numeric' export function NumericResolutionPanel(props: { creator: User - contract: NumericContract + contract: NumericContract | PseudoNumericContract className?: string }) { useEffect(() => { @@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: { }, []) const { contract, className } = props + const { min, max, outcomeType } = contract const [outcomeMode, setOutcomeMode] = useState< 'NUMBER' | 'CANCEL' | undefined @@ -32,15 +34,32 @@ export function NumericResolutionPanel(props: { const [error, setError] = useState(undefined) const resolve = async () => { - const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL' + const finalOutcome = + outcomeMode === 'CANCEL' + ? 'CANCEL' + : outcomeType === 'PSEUDO_NUMERIC' + ? 'MKT' + : 'NUMBER' if (outcomeMode === undefined || finalOutcome === undefined) return setIsSubmitting(true) + const boundedValue = Math.max(Math.min(max, value ?? 0), min) + + const probabilityInt = + 100 * + getPseudoProbability( + boundedValue, + min, + max, + outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale + ) + try { const result = await resolveMarket({ outcome: finalOutcome, value, + probabilityInt, contractId: contract.id, }) console.log('resolved', outcome, 'result:', result) @@ -77,7 +96,7 @@ export function NumericResolutionPanel(props: { {outcomeMode === 'NUMBER' && ( (setValue(v), setOutcome(o))} /> diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 054ebfd2..a94618e4 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -19,11 +19,15 @@ export function OutcomeLabel(props: { value?: number }) { const { outcome, contract, truncate, value } = props + const { outcomeType } = contract - if (contract.outcomeType === 'BINARY') + if (outcomeType === 'PSEUDO_NUMERIC') + return + + if (outcomeType === 'BINARY') return - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return ( {value ?? getValueFromBucket(outcome, contract)} @@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) { return } +export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) { + const { outcome } = props + + if (outcome === 'YES') return + if (outcome === 'NO') return + if (outcome === 'MKT') return + return +} + export function BinaryContractOutcomeLabel(props: { contract: BinaryContract resolution: resolution @@ -98,6 +111,14 @@ export function YesLabel() { return YES } +export function HigherLabel() { + return HIGHER +} + +export function LowerLabel() { + return LOWER +} + export function NoLabel() { return NO } diff --git a/web/components/sell-button.tsx b/web/components/sell-button.tsx index 2b3734a5..51c88442 100644 --- a/web/components/sell-button.tsx +++ b/web/components/sell-button.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useState } from 'react' @@ -7,7 +7,7 @@ import clsx from 'clsx' import { SellSharesModal } from './sell-modal' export function SellButton(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined sharesOutcome: 'YES' | 'NO' | undefined shares: number @@ -16,7 +16,8 @@ export function SellButton(props: { const { contract, user, sharesOutcome, shares, panelClassName } = props const userBets = useUserContractBets(user?.id, contract.id) const [showSellModal, setShowSellModal] = useState(false) - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' if (sharesOutcome && user && mechanism === 'cpmm-1') { return ( @@ -32,7 +33,10 @@ export function SellButton(props: { )} onClick={() => setShowSellModal(true)} > - {'Sell ' + sharesOutcome} + Sell{' '} + {isPseudoNumeric + ? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome] + : sharesOutcome}
{'(' + Math.floor(shares) + ' shares)'} diff --git a/web/components/sell-modal.tsx b/web/components/sell-modal.tsx index f5a1af67..63cf79b2 100644 --- a/web/components/sell-modal.tsx +++ b/web/components/sell-modal.tsx @@ -1,4 +1,4 @@ -import { CPMMBinaryContract } from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Bet } from 'common/bet' import { User } from 'common/user' import { Modal } from './layout/modal' @@ -11,7 +11,7 @@ import clsx from 'clsx' export function SellSharesModal(props: { className?: string - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx index 4fe2536f..a8cb2851 100644 --- a/web/components/sell-row.tsx +++ b/web/components/sell-row.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useState } from 'react' import { Col } from './layout/col' @@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares' import { SellSharesModal } from './sell-modal' export function SellRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined className?: string }) { diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index d040eba9..cac7bf74 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -12,6 +12,7 @@ export function YesNoSelector(props: { btnClassName?: string replaceYesButton?: React.ReactNode replaceNoButton?: React.ReactNode + isPseudoNumeric?: boolean }) { const { selected, @@ -20,6 +21,7 @@ export function YesNoSelector(props: { btnClassName, replaceNoButton, replaceYesButton, + isPseudoNumeric, } = props const commonClassNames = @@ -41,7 +43,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('YES')} > - Bet YES + {isPseudoNumeric ? 'HIGHER' : 'Bet YES'} )} {replaceNoButton ? ( @@ -58,7 +60,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('NO')} > - Bet NO + {isPseudoNumeric ? 'LOWER' : 'Bet NO'} )} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 413de725..2576c2e3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -144,10 +144,12 @@ export function ContractPageContent( const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user - const hasSidePanel = (isBinary || isNumeric) && (allowTrade || allowResolve) + const hasSidePanel = + (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) const ogCardProps = getOpenGraphProps(contract) @@ -170,7 +172,7 @@ export function ContractPageContent( ))} {allowResolve && - (isNumeric ? ( + (isNumeric || isPseudoNumeric ? ( ) : ( @@ -210,10 +212,11 @@ export function ContractPageContent( )} + {isNumeric && ( )} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7d645b04..c7b8f02e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -85,8 +85,12 @@ export function NewContract(props: { const { creator, question, groupId } = props const [outcomeType, setOutcomeType] = useState('BINARY') const [initialProb] = useState(50) + const [minString, setMinString] = useState('') const [maxString, setMaxString] = useState('') + const [isLogScale, setIsLogScale] = useState(false) + const [initialValueString, setInitialValueString] = useState('') + const [description, setDescription] = useState('') // const [tagText, setTagText] = useState(tag ?? '') // const tags = parseWordsAsTags(tagText) @@ -129,6 +133,18 @@ export function NewContract(props: { const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined + const initialValue = initialValueString + ? parseFloat(initialValueString) + : undefined + + const adjustIsLog = () => { + if (min === undefined || max === undefined) return + const lengthDiff = Math.log10(max - min) + if (lengthDiff > 2) { + setIsLogScale(true) + } + } + // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') @@ -145,13 +161,16 @@ export function NewContract(props: { // closeTime must be in the future closeTime && closeTime > Date.now() && - (outcomeType !== 'NUMERIC' || + (outcomeType !== 'PSEUDO_NUMERIC' || (min !== undefined && max !== undefined && + initialValue !== undefined && isFinite(min) && isFinite(max) && min < max && - max - min > 0.01)) + max - min > 0.01 && + min < initialValue && + initialValue < max)) function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') @@ -175,6 +194,8 @@ export function NewContract(props: { closeTime, min, max, + initialValue, + isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, tags: category ? [category] : undefined, }) @@ -220,6 +241,7 @@ export function NewContract(props: { choicesMap={{ 'Yes / No': 'BINARY', 'Free response': 'FREE_RESPONSE', + Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} @@ -232,38 +254,89 @@ export function NewContract(props: { - {outcomeType === 'NUMERIC' && ( -
- + {outcomeType === 'PSEUDO_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} - /> - -
+ + e.stopPropagation()} + onChange={(e) => setMinString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={minString ?? ''} + /> + e.stopPropagation()} + onChange={(e) => setMaxString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={maxString} + /> + + + {!(min !== undefined && min < 0) && ( + + Log scale{' '} + setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> + + )} + + {min !== undefined && max !== undefined && min >= max && ( +
+ The maximum value must be greater than the minimum. +
+ )} +
+
+ + + + e.stopPropagation()} + onChange={(e) => setInitialValueString(e.target.value)} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={initialValueString ?? ''} + /> + + + {initialValue !== undefined && + min !== undefined && + max !== undefined && + min < max && + (initialValue <= min || initialValue >= max) && ( +
+ Initial value must be in between {min} and {max}.{' '} +
+ )} +
+ )}
diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 98bf37b2..93439be7 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -7,6 +7,7 @@ import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { ContractDetails } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' @@ -79,6 +80,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { question, outcomeType } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const href = `https://${DOMAIN}${contractPath(contract)}` @@ -110,13 +112,18 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( - {/* this fails typechecking, but it doesn't explode because we will - never */} - + )} + {isPseudoNumeric && ( + + + + + )} + {outcomeType === 'FREE_RESPONSE' && (
- {isBinary && ( + {(isBinary || isPseudoNumeric) && (