Free response (#47)
* Answer datatype and MULTI outcome type for Contract
* Create free answer contract
* Automatically sort Tailwind classes with Prettier (#45)
* Add Prettier Tailwind plugin
* Autoformat Tailwind classes with Prettier
* Allow for non-binary contracts in contract page and related components
* logo with white inside, transparent bg
* Create answer
* Some UI for showing answers
* Answer bet panel
* Convert rest of calcuate file to generic multi contracts
* Working betting with ante'd NONE answer
* Numbered answers. Layout & calculation tweaks
* Can bet. More layout tweaks!
* Resolve answer UI
* Resolve multi market
* Resolved market UI
* Fix feed and cards for multi contracts
* Sell bets. Various fixes
* Tweaks for trades page
* Always dev mode
* Create answer bet has isAnte: true
* Fix card showing 0% for multi contracts
* Fix grouped bets feed for multi outcomes
* None option converted to none of the above label at bottom of list. Button to resolve none.
* Tweaks to no answers yet, resolve button layout
* Show ante bets on new answers in the feed
* Update placeholder text for description
* Consolidate firestore rules for subcollections
* Remove Contract and Bet type params. Use string type for outcomes.
* Increase char limit to 10k for answers. Preserve line breaks.
* Don't show resolve options after answer chosen
* Fix type error in script
* Remove NONE resolution option
* Change outcomeType to include 'MULTI' and 'FREE_RESPONSE'
* Show bet probability change and payout when creating answer
* User info change: also change answers
* Append answers to contract field 'answers'
* sort trades by resolved
* Don't include trailing !:,.; in links
* Stop flooring inputs into formatMoney
* Revert "Stop flooring inputs into formatMoney"
This reverts commit 2f7ab18429
.
* Consistently floor user.balance
* Expand create panel on focus
From Richard Hanania's feedback
* welcome email: include link to manifold
* Fix home page in dev on branches that are not free-response
* Close emails (#50)
* script init for stephen dev
* market close emails
* order of operations
* template email
* sendMarketCloseEmail: handle unsubscribe
* remove debugging
* marketCloseEmails: every hour
* sendMarketCloseEmails: check undefined
* marketCloseEmails: "every hour" => "every 1 hours"
* Set up a read API using Vercel serverless functions (#49)
* Set up read API using Vercel serverless functions
Featuring:
/api/v0/markets
/api/v0/market/[contractId]
/api/v0/slug/[contractSlug]
* Include tags in API
* Tweaks. Remove filter for only binary contract
* Fix bet probability change for NO bets
* Put back isProd calculation
Co-authored-by: Austin Chen <akrolsmir@gmail.com>
Co-authored-by: mantikoros <sgrugett@gmail.com>
Co-authored-by: mantikoros <95266179+mantikoros@users.noreply.github.com>
This commit is contained in:
parent
d3fdd4cd1f
commit
b2501d8145
31
common/answer.ts
Normal file
31
common/answer.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { User } from './user'
|
||||
|
||||
export type Answer = {
|
||||
id: string
|
||||
number: number
|
||||
contractId: string
|
||||
createdTime: number
|
||||
|
||||
userId: string
|
||||
username: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
|
||||
text: string
|
||||
}
|
||||
|
||||
export const getNoneAnswer = (contractId: string, creator: User) => {
|
||||
const { username, name, avatarUrl } = creator
|
||||
|
||||
return {
|
||||
id: '0',
|
||||
number: 0,
|
||||
contractId,
|
||||
createdTime: Date.now(),
|
||||
userId: creator.id,
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
text: 'None',
|
||||
}
|
||||
}
|
|
@ -61,3 +61,30 @@ export function getAnteBets(
|
|||
|
||||
return { yesBet, noBet }
|
||||
}
|
||||
|
||||
export function getFreeAnswerAnte(
|
||||
creator: User,
|
||||
contract: Contract,
|
||||
anteBetId: string
|
||||
) {
|
||||
const { totalBets, totalShares } = contract
|
||||
const amount = totalBets['0']
|
||||
const shares = totalShares['0']
|
||||
|
||||
const { createdTime } = contract
|
||||
|
||||
const anteBet: Bet = {
|
||||
id: anteBetId,
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
outcome: '0',
|
||||
probBefore: 0,
|
||||
probAfter: 1,
|
||||
createdTime,
|
||||
isAnte: true,
|
||||
}
|
||||
|
||||
return anteBet
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ export type Bet = {
|
|||
contractId: string
|
||||
|
||||
amount: number // bet size; negative if SELL bet
|
||||
outcome: 'YES' | 'NO'
|
||||
outcome: string
|
||||
shares: number // dynamic parimutuel pool weight; negative if SELL bet
|
||||
|
||||
probBefore: number
|
||||
|
|
|
@ -1,69 +1,72 @@
|
|||
import * as _ from 'lodash'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
import { FEES } from './fees'
|
||||
|
||||
export function getProbability(totalShares: { YES: number; NO: number }) {
|
||||
const { YES: y, NO: n } = totalShares
|
||||
return y ** 2 / (y ** 2 + n ** 2)
|
||||
export function getProbability(totalShares: { [outcome: string]: number }) {
|
||||
// For binary contracts only.
|
||||
return getOutcomeProbability(totalShares, 'YES')
|
||||
}
|
||||
|
||||
export function getOutcomeProbability(
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
outcome: string
|
||||
) {
|
||||
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
|
||||
const shares = totalShares[outcome] ?? 0
|
||||
return shares ** 2 / squareSum
|
||||
}
|
||||
|
||||
export function getProbabilityAfterBet(
|
||||
totalShares: { YES: number; NO: number },
|
||||
outcome: 'YES' | 'NO',
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
outcome: string,
|
||||
bet: number
|
||||
) {
|
||||
const shares = calculateShares(totalShares, bet, outcome)
|
||||
|
||||
const [YES, NO] =
|
||||
outcome === 'YES'
|
||||
? [totalShares.YES + shares, totalShares.NO]
|
||||
: [totalShares.YES, totalShares.NO + shares]
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const newTotalShares = { ...totalShares, [outcome]: prevShares + shares }
|
||||
|
||||
return getProbability({ YES, NO })
|
||||
return getOutcomeProbability(newTotalShares, outcome)
|
||||
}
|
||||
|
||||
export function calculateShares(
|
||||
totalShares: { YES: number; NO: number },
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
bet: number,
|
||||
betChoice: 'YES' | 'NO'
|
||||
betChoice: string
|
||||
) {
|
||||
const [yesShares, noShares] = [totalShares.YES, totalShares.NO]
|
||||
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
|
||||
const shares = totalShares[betChoice] ?? 0
|
||||
|
||||
const c = 2 * bet * Math.sqrt(yesShares ** 2 + noShares ** 2)
|
||||
const c = 2 * bet * Math.sqrt(squareSum)
|
||||
|
||||
return betChoice === 'YES'
|
||||
? Math.sqrt(bet ** 2 + yesShares ** 2 + c) - yesShares
|
||||
: Math.sqrt(bet ** 2 + noShares ** 2 + c) - noShares
|
||||
}
|
||||
|
||||
export function calculateEstimatedWinnings(
|
||||
totalShares: { YES: number; NO: number },
|
||||
shares: number,
|
||||
betChoice: 'YES' | 'NO'
|
||||
) {
|
||||
const ind = betChoice === 'YES' ? 1 : 0
|
||||
|
||||
const yesShares = totalShares.YES + ind * shares
|
||||
const noShares = totalShares.NO + (1 - ind) * shares
|
||||
|
||||
const estPool = Math.sqrt(yesShares ** 2 + noShares ** 2)
|
||||
const total = ind * yesShares + (1 - ind) * noShares
|
||||
|
||||
return ((1 - FEES) * (shares * estPool)) / total
|
||||
return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares
|
||||
}
|
||||
|
||||
export function calculateRawShareValue(
|
||||
totalShares: { YES: number; NO: number },
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
shares: number,
|
||||
betChoice: 'YES' | 'NO'
|
||||
betChoice: string
|
||||
) {
|
||||
const [yesShares, noShares] = [totalShares.YES, totalShares.NO]
|
||||
const currentValue = Math.sqrt(yesShares ** 2 + noShares ** 2)
|
||||
const currentValue = Math.sqrt(
|
||||
_.sumBy(Object.values(totalShares), (shares) => shares ** 2)
|
||||
)
|
||||
|
||||
const postSaleValue =
|
||||
betChoice === 'YES'
|
||||
? Math.sqrt(Math.max(0, yesShares - shares) ** 2 + noShares ** 2)
|
||||
: Math.sqrt(yesShares ** 2 + Math.max(0, noShares - shares) ** 2)
|
||||
const postSaleValue = Math.sqrt(
|
||||
_.sumBy(Object.keys(totalShares), (outcome) =>
|
||||
outcome === betChoice
|
||||
? Math.max(0, totalShares[outcome] - shares) ** 2
|
||||
: totalShares[outcome] ** 2
|
||||
)
|
||||
)
|
||||
|
||||
return currentValue - postSaleValue
|
||||
}
|
||||
|
@ -73,17 +76,22 @@ export function calculateMoneyRatio(
|
|||
bet: Bet,
|
||||
shareValue: number
|
||||
) {
|
||||
const { totalShares, pool } = contract
|
||||
const { totalShares, totalBets, pool } = contract
|
||||
const { outcome, amount } = bet
|
||||
|
||||
const p = getProbability(totalShares)
|
||||
const p = getOutcomeProbability(totalShares, outcome)
|
||||
|
||||
const actual = pool.YES + pool.NO - shareValue
|
||||
const actual = _.sum(Object.values(pool)) - shareValue
|
||||
|
||||
const betAmount =
|
||||
bet.outcome === 'YES' ? p * bet.amount : (1 - p) * bet.amount
|
||||
const betAmount = p * amount
|
||||
|
||||
const expected =
|
||||
p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO - betAmount
|
||||
_.sumBy(
|
||||
Object.keys(totalBets),
|
||||
(outcome) =>
|
||||
getOutcomeProbability(totalShares, outcome) *
|
||||
(totalBets as { [outcome: string]: number })[outcome]
|
||||
) - betAmount
|
||||
|
||||
if (actual <= 0 || expected <= 0) return 0
|
||||
|
||||
|
@ -91,14 +99,13 @@ export function calculateMoneyRatio(
|
|||
}
|
||||
|
||||
export function calculateShareValue(contract: Contract, bet: Bet) {
|
||||
const shareValue = calculateRawShareValue(
|
||||
contract.totalShares,
|
||||
bet.shares,
|
||||
bet.outcome
|
||||
)
|
||||
const { pool, totalShares } = contract
|
||||
const { shares, outcome } = bet
|
||||
|
||||
const shareValue = calculateRawShareValue(totalShares, shares, outcome)
|
||||
const f = calculateMoneyRatio(contract, bet, shareValue)
|
||||
|
||||
const myPool = contract.pool[bet.outcome]
|
||||
const myPool = pool[outcome]
|
||||
const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool)
|
||||
return adjShareValue
|
||||
}
|
||||
|
@ -109,11 +116,7 @@ export function calculateSaleAmount(contract: Contract, bet: Bet) {
|
|||
return deductFees(amount, winnings)
|
||||
}
|
||||
|
||||
export function calculatePayout(
|
||||
contract: Contract,
|
||||
bet: Bet,
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||
) {
|
||||
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
|
||||
if (outcome === 'CANCEL') return calculateCancelPayout(contract, bet)
|
||||
if (outcome === 'MKT') return calculateMktPayout(contract, bet)
|
||||
|
||||
|
@ -121,67 +124,100 @@ export function calculatePayout(
|
|||
}
|
||||
|
||||
export function calculateCancelPayout(contract: Contract, bet: Bet) {
|
||||
const totalBets = contract.totalBets.YES + contract.totalBets.NO
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
const { totalBets, pool } = contract
|
||||
const betTotal = _.sum(Object.values(totalBets))
|
||||
const poolTotal = _.sum(Object.values(pool))
|
||||
|
||||
return (bet.amount / totalBets) * pool
|
||||
return (bet.amount / betTotal) * poolTotal
|
||||
}
|
||||
|
||||
export function calculateStandardPayout(
|
||||
contract: Contract,
|
||||
bet: Bet,
|
||||
outcome: 'YES' | 'NO'
|
||||
outcome: string
|
||||
) {
|
||||
const { amount, outcome: betOutcome, shares } = bet
|
||||
if (betOutcome !== outcome) return 0
|
||||
|
||||
const { totalShares, phantomShares } = contract
|
||||
if (totalShares[outcome] === 0) return 0
|
||||
const { totalShares, phantomShares, pool } = contract
|
||||
if (!totalShares[outcome]) return 0
|
||||
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
const total = totalShares[outcome] - phantomShares[outcome]
|
||||
const poolTotal = _.sum(Object.values(pool))
|
||||
|
||||
const winnings = (shares / total) * pool
|
||||
const total =
|
||||
totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0)
|
||||
|
||||
const winnings = (shares / total) * poolTotal
|
||||
// profit can be negative if using phantom shares
|
||||
return amount + (1 - FEES) * Math.max(0, winnings - amount)
|
||||
}
|
||||
|
||||
export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
|
||||
const { totalShares, pool, totalBets } = contract
|
||||
const { shares, amount, outcome } = bet
|
||||
|
||||
const ind = bet.outcome === 'YES' ? 1 : 0
|
||||
const { shares, amount } = bet
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const prevPool = pool[outcome] ?? 0
|
||||
const prevTotalBet = totalBets[outcome] ?? 0
|
||||
|
||||
const newContract = {
|
||||
...contract,
|
||||
totalShares: {
|
||||
YES: totalShares.YES + ind * shares,
|
||||
NO: totalShares.NO + (1 - ind) * shares,
|
||||
...totalShares,
|
||||
[outcome]: prevShares + shares,
|
||||
},
|
||||
pool: {
|
||||
YES: pool.YES + ind * amount,
|
||||
NO: pool.NO + (1 - ind) * amount,
|
||||
...pool,
|
||||
[outcome]: prevPool + amount,
|
||||
},
|
||||
totalBets: {
|
||||
YES: totalBets.YES + ind * amount,
|
||||
NO: totalBets.NO + (1 - ind) * amount,
|
||||
...totalBets,
|
||||
[outcome]: prevTotalBet + amount,
|
||||
},
|
||||
}
|
||||
|
||||
return calculateStandardPayout(newContract, bet, bet.outcome)
|
||||
return calculateStandardPayout(newContract, bet, outcome)
|
||||
}
|
||||
|
||||
function calculateMktPayout(contract: Contract, bet: Bet) {
|
||||
if (contract.outcomeType === 'BINARY')
|
||||
return calculateBinaryMktPayout(contract, bet)
|
||||
|
||||
const { totalShares, pool } = contract
|
||||
|
||||
const totalPool = _.sum(Object.values(pool))
|
||||
const sharesSquareSum = _.sumBy(
|
||||
Object.values(totalShares),
|
||||
(shares) => shares ** 2
|
||||
)
|
||||
|
||||
const weightedShareTotal = _.sumBy(Object.keys(totalShares), (outcome) => {
|
||||
// Avoid O(n^2) by reusing sharesSquareSum for prob.
|
||||
const shares = totalShares[outcome]
|
||||
const prob = shares ** 2 / sharesSquareSum
|
||||
return prob * shares
|
||||
})
|
||||
|
||||
const { outcome, amount, shares } = bet
|
||||
|
||||
const betP = getOutcomeProbability(totalShares, outcome)
|
||||
const winnings = ((betP * shares) / weightedShareTotal) * totalPool
|
||||
|
||||
return deductFees(amount, winnings)
|
||||
}
|
||||
|
||||
function calculateBinaryMktPayout(contract: Contract, bet: Bet) {
|
||||
const { resolutionProbability, totalShares, phantomShares } = contract
|
||||
const p =
|
||||
contract.resolutionProbability !== undefined
|
||||
? contract.resolutionProbability
|
||||
: getProbability(contract.totalShares)
|
||||
resolutionProbability !== undefined
|
||||
? resolutionProbability
|
||||
: getProbability(totalShares)
|
||||
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
|
||||
const weightedShareTotal =
|
||||
p * (contract.totalShares.YES - contract.phantomShares.YES) +
|
||||
(1 - p) * (contract.totalShares.NO - contract.phantomShares.NO)
|
||||
p * (totalShares.YES - (phantomShares?.YES ?? 0)) +
|
||||
(1 - p) * (totalShares.NO - (phantomShares?.NO ?? 0))
|
||||
|
||||
const { outcome, amount, shares } = bet
|
||||
|
||||
|
@ -197,15 +233,6 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
|
|||
throw new Error('Contract was not resolved')
|
||||
}
|
||||
|
||||
// deprecated use MKT payout
|
||||
export function currentValue(contract: Contract, bet: Bet) {
|
||||
const prob = getProbability(contract.pool)
|
||||
const yesPayout = calculatePayout(contract, bet, 'YES')
|
||||
const noPayout = calculatePayout(contract, bet, 'NO')
|
||||
|
||||
return prob * yesPayout + (1 - prob) * noPayout
|
||||
}
|
||||
|
||||
export const deductFees = (betAmount: number, winnings: number) => {
|
||||
return winnings > betAmount
|
||||
? betAmount + (1 - FEES) * (winnings - betAmount)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { Answer } from './answer'
|
||||
|
||||
export type Contract = {
|
||||
id: string
|
||||
slug: string // auto-generated; must be unique
|
||||
|
@ -11,14 +13,17 @@ export type Contract = {
|
|||
description: string // More info about what the contract is about
|
||||
tags: string[]
|
||||
lowercaseTags: string[]
|
||||
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
|
||||
visibility: 'public' | 'unlisted'
|
||||
|
||||
outcomeType: 'BINARY' | 'MULTI' | 'FREE_RESPONSE'
|
||||
multiOutcomes?: string[] // Used for outcomeType 'MULTI'.
|
||||
answers?: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
|
||||
|
||||
mechanism: 'dpm-2'
|
||||
phantomShares: { YES: number; NO: number }
|
||||
pool: { YES: number; NO: number }
|
||||
totalShares: { YES: number; NO: number }
|
||||
totalBets: { YES: number; NO: number }
|
||||
phantomShares?: { [outcome: string]: number }
|
||||
pool: { [outcome: string]: number }
|
||||
totalShares: { [outcome: string]: number }
|
||||
totalBets: { [outcome: string]: number }
|
||||
|
||||
createdTime: number // Milliseconds since epoch
|
||||
lastUpdatedTime: number // If the question or description was changed
|
||||
|
@ -26,7 +31,7 @@ export type Contract = {
|
|||
|
||||
isResolved: boolean
|
||||
resolutionTime?: number // When the contract creator resolved the market
|
||||
resolution?: outcome // Chosen by creator; must be one of outcomes
|
||||
resolution?: string
|
||||
resolutionProbability?: number
|
||||
closeEmailsSent?: number
|
||||
|
||||
|
@ -34,4 +39,4 @@ export type Contract = {
|
|||
volume7Days: number
|
||||
}
|
||||
|
||||
export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE'
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import { Bet } from './bet'
|
||||
import { calculateShares, getProbability } from './calculate'
|
||||
import {
|
||||
calculateShares,
|
||||
getProbability,
|
||||
getOutcomeProbability,
|
||||
} from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { User } from './user'
|
||||
|
||||
export const getNewBetInfo = (
|
||||
export const getNewBinaryBetInfo = (
|
||||
user: User,
|
||||
outcome: 'YES' | 'NO',
|
||||
amount: number,
|
||||
|
@ -52,3 +56,43 @@ export const getNewBetInfo = (
|
|||
|
||||
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
|
||||
}
|
||||
|
||||
export const getNewMultiBetInfo = (
|
||||
user: User,
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: Contract,
|
||||
newBetId: string
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
const prevOutcomePool = pool[outcome] ?? 0
|
||||
const newPool = { ...pool, [outcome]: prevOutcomePool + amount }
|
||||
|
||||
const shares = calculateShares(contract.totalShares, amount, outcome)
|
||||
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const newTotalShares = { ...totalShares, [outcome]: prevShares + shares }
|
||||
|
||||
const prevTotalBets = totalBets[outcome] ?? 0
|
||||
const newTotalBets = { ...totalBets, [outcome]: prevTotalBets + amount }
|
||||
|
||||
const probBefore = getOutcomeProbability(totalShares, outcome)
|
||||
const probAfter = getOutcomeProbability(newTotalShares, outcome)
|
||||
|
||||
const newBet: Bet = {
|
||||
id: newBetId,
|
||||
userId: user.id,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
outcome,
|
||||
probBefore,
|
||||
probAfter,
|
||||
createdTime: Date.now(),
|
||||
}
|
||||
|
||||
const newBalance = user.balance - amount
|
||||
|
||||
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
|
||||
}
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
import { calcStartPool } from './antes'
|
||||
|
||||
import { Contract } from './contract'
|
||||
import { Contract, outcomeType } from './contract'
|
||||
import { User } from './user'
|
||||
import { parseTags } from './util/parse'
|
||||
import { removeUndefinedProps } from './util/object'
|
||||
|
||||
export function getNewContract(
|
||||
id: string,
|
||||
slug: string,
|
||||
creator: User,
|
||||
question: string,
|
||||
outcomeType: outcomeType,
|
||||
description: string,
|
||||
initialProb: number,
|
||||
ante: number,
|
||||
closeTime: number,
|
||||
extraTags: string[]
|
||||
) {
|
||||
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
|
||||
calcStartPool(initialProb, ante)
|
||||
|
||||
const tags = parseTags(
|
||||
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
|
||||
)
|
||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
||||
|
||||
const contract: Contract = {
|
||||
const propsByOutcomeType =
|
||||
outcomeType === 'BINARY'
|
||||
? getBinaryProps(initialProb, ante)
|
||||
: getFreeAnswerProps(ante)
|
||||
|
||||
const contract: Contract = removeUndefinedProps({
|
||||
id,
|
||||
slug,
|
||||
outcomeType: 'BINARY',
|
||||
mechanism: 'dpm-2',
|
||||
outcomeType,
|
||||
...propsByOutcomeType,
|
||||
|
||||
creatorId: creator.id,
|
||||
creatorName: creator.name,
|
||||
|
@ -38,22 +43,43 @@ export function getNewContract(
|
|||
tags,
|
||||
lowercaseTags,
|
||||
visibility: 'public',
|
||||
isResolved: false,
|
||||
createdTime: Date.now(),
|
||||
lastUpdatedTime: Date.now(),
|
||||
closeTime,
|
||||
|
||||
mechanism: 'dpm-2',
|
||||
volume24Hours: 0,
|
||||
volume7Days: 0,
|
||||
})
|
||||
|
||||
return contract
|
||||
}
|
||||
|
||||
const getBinaryProps = (initialProb: number, ante: number) => {
|
||||
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
|
||||
calcStartPool(initialProb, ante)
|
||||
|
||||
return {
|
||||
phantomShares: { YES: phantomYes, NO: phantomNo },
|
||||
pool: { YES: poolYes, NO: poolNo },
|
||||
totalShares: { YES: sharesYes, NO: sharesNo },
|
||||
totalBets: { YES: poolYes, NO: poolNo },
|
||||
isResolved: false,
|
||||
|
||||
createdTime: Date.now(),
|
||||
lastUpdatedTime: Date.now(),
|
||||
|
||||
volume24Hours: 0,
|
||||
volume7Days: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (closeTime) contract.closeTime = closeTime
|
||||
|
||||
return contract
|
||||
const getFreeAnswerProps = (ante: number) => {
|
||||
return {
|
||||
pool: { '0': ante },
|
||||
totalShares: { '0': ante },
|
||||
totalBets: { '0': ante },
|
||||
answers: [],
|
||||
}
|
||||
}
|
||||
|
||||
const getMultiProps = (
|
||||
outcomes: string[],
|
||||
initialProbs: number[],
|
||||
ante: number
|
||||
) => {
|
||||
// Not implemented.
|
||||
}
|
||||
|
|
|
@ -2,12 +2,12 @@ import * as _ from 'lodash'
|
|||
|
||||
import { Bet } from './bet'
|
||||
import { deductFees, getProbability } from './calculate'
|
||||
import { Contract, outcome } from './contract'
|
||||
import { Contract } from './contract'
|
||||
import { CREATOR_FEE, FEES } from './fees'
|
||||
|
||||
export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
|
||||
const { pool } = contract
|
||||
const poolTotal = pool.YES + pool.NO
|
||||
const poolTotal = _.sum(Object.values(pool))
|
||||
console.log('resolved N/A, pool M$', poolTotal)
|
||||
|
||||
const betSum = _.sumBy(bets, (b) => b.amount)
|
||||
|
@ -19,18 +19,17 @@ export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
|
|||
}
|
||||
|
||||
export const getStandardPayouts = (
|
||||
outcome: 'YES' | 'NO',
|
||||
outcome: string,
|
||||
contract: Contract,
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
|
||||
const winningBets = outcome === 'YES' ? yesBets : noBets
|
||||
const winningBets = bets.filter((bet) => bet.outcome === outcome)
|
||||
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
const poolTotal = _.sum(Object.values(contract.pool))
|
||||
const totalShares = _.sumBy(winningBets, (b) => b.shares)
|
||||
|
||||
const payouts = winningBets.map(({ userId, amount, shares }) => {
|
||||
const winnings = (shares / totalShares) * pool
|
||||
const winnings = (shares / totalShares) * poolTotal
|
||||
const profit = winnings - amount
|
||||
|
||||
// profit can be negative if using phantom shares
|
||||
|
@ -45,7 +44,7 @@ export const getStandardPayouts = (
|
|||
'resolved',
|
||||
outcome,
|
||||
'pool',
|
||||
pool,
|
||||
poolTotal,
|
||||
'profits',
|
||||
profits,
|
||||
'creator fee',
|
||||
|
@ -101,7 +100,7 @@ export const getMktPayouts = (
|
|||
}
|
||||
|
||||
export const getPayouts = (
|
||||
outcome: outcome,
|
||||
outcome: string,
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
resolutionProbability?: number
|
||||
|
@ -114,5 +113,8 @@ export const getPayouts = (
|
|||
return getMktPayouts(contract, bets, resolutionProbability)
|
||||
case 'CANCEL':
|
||||
return getCancelPayouts(contract, bets)
|
||||
default:
|
||||
// Multi outcome.
|
||||
return getStandardPayouts(outcome, contract, bets)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Bet } from './bet'
|
||||
import { calculateShareValue, deductFees, getProbability } from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { CREATOR_FEE, FEES } from './fees'
|
||||
import { CREATOR_FEE } from './fees'
|
||||
import { User } from './user'
|
||||
|
||||
export const getSellBetInfo = (
|
||||
|
@ -10,30 +10,21 @@ export const getSellBetInfo = (
|
|||
contract: Contract,
|
||||
newBetId: string
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
const { id: betId, amount, shares, outcome } = bet
|
||||
|
||||
const { YES: yesPool, NO: noPool } = contract.pool
|
||||
const { YES: yesShares, NO: noShares } = contract.totalShares
|
||||
const { YES: yesBets, NO: noBets } = contract.totalBets
|
||||
|
||||
const adjShareValue = calculateShareValue(contract, bet)
|
||||
|
||||
const newPool =
|
||||
outcome === 'YES'
|
||||
? { YES: yesPool - adjShareValue, NO: noPool }
|
||||
: { YES: yesPool, NO: noPool - adjShareValue }
|
||||
const newPool = { ...pool, [outcome]: pool[outcome] - adjShareValue }
|
||||
|
||||
const newTotalShares =
|
||||
outcome === 'YES'
|
||||
? { YES: yesShares - shares, NO: noShares }
|
||||
: { YES: yesShares, NO: noShares - shares }
|
||||
const newTotalShares = {
|
||||
...totalShares,
|
||||
[outcome]: totalShares[outcome] - shares,
|
||||
}
|
||||
|
||||
const newTotalBets =
|
||||
outcome === 'YES'
|
||||
? { YES: yesBets - amount, NO: noBets }
|
||||
: { YES: yesBets, NO: noBets - amount }
|
||||
const newTotalBets = { ...totalBets, [outcome]: totalBets[outcome] - amount }
|
||||
|
||||
const probBefore = getProbability(contract.totalShares)
|
||||
const probBefore = getProbability(totalShares)
|
||||
const probAfter = getProbability(newTotalShares)
|
||||
|
||||
const profit = adjShareValue - amount
|
||||
|
|
9
common/util/object.ts
Normal file
9
common/util/object.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export const removeUndefinedProps = <T>(obj: T): T => {
|
||||
let newObj: any = {}
|
||||
|
||||
for (let key of Object.keys(obj)) {
|
||||
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
|
||||
}
|
||||
|
||||
return newObj
|
||||
}
|
|
@ -27,20 +27,16 @@ service cloud.firestore {
|
|||
allow delete: if resource.data.creatorId == request.auth.uid;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/bets/{betId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/bets/{betId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/comments/{commentId} {
|
||||
match /{somePath=**}/comments/{commentId} {
|
||||
allow read;
|
||||
allow create: if request.auth != null;
|
||||
}
|
||||
|
||||
match /{somePath=**}/comments/{commentId} {
|
||||
match /{somePath=**}/answers/{answerId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
|
@ -49,13 +45,9 @@ service cloud.firestore {
|
|||
allow update, delete: if request.auth.uid == resource.data.curatorId;
|
||||
}
|
||||
|
||||
match /folds/{foldId}/followers/{userId} {
|
||||
match /{somePath=**}/followers/{userId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
}
|
||||
|
||||
match /{somePath=**}/followers/{userId} {
|
||||
allow read;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser, removeUndefinedProps } from './utils'
|
||||
import { getUser } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { User } from '../../common/user'
|
||||
import { cleanUsername } from '../../common/util/clean-username'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
||||
export const changeUserInfo = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
|
@ -88,12 +90,23 @@ export const changeUser = async (
|
|||
userAvatarUrl: update.avatarUrl,
|
||||
})
|
||||
|
||||
const answerSnap = await transaction.get(
|
||||
firestore
|
||||
.collectionGroup('answers')
|
||||
.where('username', '==', user.username)
|
||||
)
|
||||
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
||||
|
||||
await transaction.update(userRef, userUpdate)
|
||||
|
||||
await Promise.all(
|
||||
commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate))
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate))
|
||||
)
|
||||
|
||||
await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate))
|
||||
})
|
||||
}
|
||||
|
|
111
functions/src/create-answer.ts
Normal file
111
functions/src/create-answer.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { getNewMultiBetInfo } from '../../common/new-bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { getValues } from './utils'
|
||||
|
||||
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
amount: number
|
||||
text: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId, amount, text } = data
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
if (!text || typeof text !== 'string' || text.length > 10000)
|
||||
return { status: 'error', message: 'Invalid text' }
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists)
|
||||
return { status: 'error', message: 'User not found' }
|
||||
const user = userSnap.data() as User
|
||||
|
||||
if (user.balance < amount)
|
||||
return { status: 'error', message: 'Insufficient balance' }
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
if (contract.outcomeType !== 'FREE_RESPONSE')
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Requires a free response contract',
|
||||
}
|
||||
|
||||
const { closeTime } = contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
return { status: 'error', message: 'Trading is closed' }
|
||||
|
||||
const [lastAnswer] = await getValues<Answer>(
|
||||
firestore
|
||||
.collection(`contracts/${contractId}/answers`)
|
||||
.orderBy('number', 'desc')
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if (!lastAnswer)
|
||||
return { status: 'error', message: 'Could not fetch last answer' }
|
||||
|
||||
const number = lastAnswer.number + 1
|
||||
const id = `${number}`
|
||||
|
||||
const newAnswerDoc = firestore
|
||||
.collection(`contracts/${contractId}/answers`)
|
||||
.doc(id)
|
||||
|
||||
const answerId = newAnswerDoc.id
|
||||
const { username, name, avatarUrl } = user
|
||||
|
||||
const answer: Answer = {
|
||||
id,
|
||||
number,
|
||||
contractId,
|
||||
createdTime: Date.now(),
|
||||
userId: user.id,
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
text,
|
||||
}
|
||||
transaction.create(newAnswerDoc, answer)
|
||||
|
||||
const newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
|
||||
getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id)
|
||||
|
||||
transaction.create(newBetDoc, { ...newBet, isAnte: true })
|
||||
transaction.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalShares: newTotalShares,
|
||||
totalBets: newTotalBets,
|
||||
answers: [...(contract.answers ?? []), answer],
|
||||
})
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
|
||||
return { status: 'success', answerId, betId: newBetDoc.id }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -2,11 +2,16 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { chargeUser, getUser } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, outcomeType } from '../../common/contract'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
import { getAnteBets, MINIMUM_ANTE } from '../../common/antes'
|
||||
import {
|
||||
getAnteBets,
|
||||
getFreeAnswerAnte,
|
||||
MINIMUM_ANTE,
|
||||
} from '../../common/antes'
|
||||
import { getNoneAnswer } from '../../common/answer'
|
||||
|
||||
export const createContract = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
|
@ -14,6 +19,7 @@ export const createContract = functions
|
|||
async (
|
||||
data: {
|
||||
question: string
|
||||
outcomeType: outcomeType
|
||||
description: string
|
||||
initialProb: number
|
||||
ante: number
|
||||
|
@ -30,10 +36,17 @@ export const createContract = functions
|
|||
|
||||
const { question, description, initialProb, ante, closeTime, tags } = data
|
||||
|
||||
if (!question || !initialProb)
|
||||
return { status: 'error', message: 'Missing contract attributes' }
|
||||
if (!question)
|
||||
return { status: 'error', message: 'Missing question field' }
|
||||
|
||||
if (initialProb < 1 || initialProb > 99)
|
||||
let outcomeType = data.outcomeType ?? 'BINARY'
|
||||
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
|
||||
return { status: 'error', message: 'Invalid outcomeType' }
|
||||
|
||||
if (
|
||||
outcomeType === 'BINARY' &&
|
||||
(!initialProb || initialProb < 1 || initialProb > 99)
|
||||
)
|
||||
return { status: 'error', message: 'Invalid initial probability' }
|
||||
|
||||
if (
|
||||
|
@ -63,6 +76,7 @@ export const createContract = functions
|
|||
slug,
|
||||
creator,
|
||||
question,
|
||||
outcomeType,
|
||||
description,
|
||||
initialProb,
|
||||
ante,
|
||||
|
@ -75,6 +89,7 @@ export const createContract = functions
|
|||
await contractRef.create(contract)
|
||||
|
||||
if (ante) {
|
||||
if (outcomeType === 'BINARY') {
|
||||
const yesBetDoc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
|
@ -91,6 +106,19 @@ export const createContract = functions
|
|||
)
|
||||
await yesBetDoc.set(yesBet)
|
||||
await noBetDoc.set(noBet)
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
const noneAnswerDoc = firestore
|
||||
.collection(`contracts/${contract.id}/answers`)
|
||||
.doc('0')
|
||||
const noneAnswer = getNoneAnswer(contract.id, creator)
|
||||
await noneAnswerDoc.set(noneAnswer)
|
||||
|
||||
const anteBetDoc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
const anteBet = getFreeAnswerAnte(creator, contract, anteBetDoc.id)
|
||||
await anteBetDoc.set(anteBet)
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'success', contract }
|
||||
|
|
|
@ -17,12 +17,23 @@ type market_resolved_template = {
|
|||
url: string
|
||||
}
|
||||
|
||||
const toDisplayResolution = (outcome: string, prob: number) => {
|
||||
const display = {
|
||||
YES: 'YES',
|
||||
NO: 'NO',
|
||||
CANCEL: 'N/A',
|
||||
MKT: formatPercent(prob),
|
||||
}[outcome]
|
||||
|
||||
return display === undefined ? `#${outcome}` : display
|
||||
}
|
||||
|
||||
export const sendMarketResolutionEmail = async (
|
||||
userId: string,
|
||||
payout: number,
|
||||
creator: User,
|
||||
contract: Contract,
|
||||
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT',
|
||||
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
|
||||
resolutionProbability?: number
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
|
@ -38,13 +49,7 @@ export const sendMarketResolutionEmail = async (
|
|||
|
||||
const prob = resolutionProbability ?? getProbability(contract.totalShares)
|
||||
|
||||
const toDisplayResolution = {
|
||||
YES: 'YES',
|
||||
NO: 'NO',
|
||||
CANCEL: 'N/A',
|
||||
MKT: formatPercent(prob),
|
||||
}
|
||||
const outcome = toDisplayResolution[resolution]
|
||||
const outcome = toDisplayResolution(resolution, prob)
|
||||
|
||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ export * from './sell-bet'
|
|||
export * from './create-contract'
|
||||
export * from './create-user'
|
||||
export * from './create-fold'
|
||||
export * from './create-answer'
|
||||
export * from './on-fold-follow'
|
||||
export * from './on-fold-delete'
|
||||
export * from './unsubscribe'
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
|
|||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { getNewBetInfo } from '../../common/new-bet'
|
||||
import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet'
|
||||
|
||||
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
|
@ -22,7 +22,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
if (outcome !== 'YES' && outcome !== 'NO')
|
||||
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
|
@ -42,16 +42,32 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
const { closeTime } = contract
|
||||
const { closeTime, outcomeType } = contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
return { status: 'error', message: 'Trading is closed' }
|
||||
|
||||
if (outcomeType === 'FREE_RESPONSE') {
|
||||
const answerSnap = await transaction.get(
|
||||
contractDoc.collection('answers').doc(outcome)
|
||||
)
|
||||
if (!answerSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
}
|
||||
|
||||
const newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
|
||||
getNewBetInfo(user, outcome, amount, contract, newBetDoc.id)
|
||||
outcomeType === 'BINARY'
|
||||
? getNewBinaryBetInfo(
|
||||
user,
|
||||
outcome as 'YES' | 'NO',
|
||||
amount,
|
||||
contract,
|
||||
newBetDoc.id
|
||||
)
|
||||
: getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id)
|
||||
|
||||
transaction.create(newBetDoc, newBet)
|
||||
transaction.update(contractDoc, {
|
||||
|
|
|
@ -25,10 +25,25 @@ export const resolveMarket = functions
|
|||
|
||||
const { outcome, contractId, probabilityInt } = data
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId, outcomeType } = contract
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
if (outcome !== 'CANCEL' && isNaN(+outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else {
|
||||
return { status: 'error', message: 'Invalid contract outcomeType' }
|
||||
}
|
||||
|
||||
if (
|
||||
outcomeType === 'BINARY' &&
|
||||
probabilityInt !== undefined &&
|
||||
(probabilityInt < 0 ||
|
||||
probabilityInt > 100 ||
|
||||
|
@ -36,19 +51,13 @@ export const resolveMarket = functions
|
|||
)
|
||||
return { status: 'error', message: 'Invalid probability' }
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
if (contract.creatorId !== userId)
|
||||
if (creatorId !== userId)
|
||||
return { status: 'error', message: 'User not creator of contract' }
|
||||
|
||||
if (contract.resolution)
|
||||
return { status: 'error', message: 'Contract already resolved' }
|
||||
|
||||
const creator = await getUser(contract.creatorId)
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) return { status: 'error', message: 'Creator not found' }
|
||||
|
||||
const resolutionProbability =
|
||||
|
@ -112,7 +121,7 @@ const sendResolutionEmails = async (
|
|||
userPayouts: { [userId: string]: number },
|
||||
creator: User,
|
||||
contract: Contract,
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT',
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
|
||||
resolutionProbability?: number
|
||||
) => {
|
||||
const nonWinners = _.difference(
|
||||
|
|
|
@ -83,11 +83,15 @@ async function recalculateContract(
|
|||
let totalShares = {
|
||||
YES: Math.sqrt(p) * (phantomAnte + realAnte),
|
||||
NO: Math.sqrt(1 - p) * (phantomAnte + realAnte),
|
||||
} as { [outcome: string]: number }
|
||||
|
||||
let pool = { YES: p * realAnte, NO: (1 - p) * realAnte } as {
|
||||
[outcome: string]: number
|
||||
}
|
||||
|
||||
let pool = { YES: p * realAnte, NO: (1 - p) * realAnte }
|
||||
|
||||
let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte }
|
||||
let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } as {
|
||||
[outcome: string]: number
|
||||
}
|
||||
|
||||
const betsRef = contractRef.collection('bets')
|
||||
|
||||
|
|
|
@ -77,13 +77,3 @@ export const chargeUser = (userId: string, charge: number) => {
|
|||
|
||||
return updateUserBalance(userId, -charge)
|
||||
}
|
||||
|
||||
export const removeUndefinedProps = <T>(obj: T): T => {
|
||||
let newObj: any = {}
|
||||
|
||||
for (let key of Object.keys(obj)) {
|
||||
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
|
||||
}
|
||||
|
||||
return newObj
|
||||
}
|
||||
|
|
546
web/components/answers-panel.tsx
Normal file
546
web/components/answers-panel.tsx
Normal file
|
@ -0,0 +1,546 @@
|
|||
import clsx from 'clsx'
|
||||
import _ from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Answer } from '../../common/answer'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { AmountInput } from './amount-input'
|
||||
import { Col } from './layout/col'
|
||||
import { createAnswer, placeBet, resolveMarket } from '../lib/firebase/api-call'
|
||||
import { Row } from './layout/row'
|
||||
import { Avatar } from './avatar'
|
||||
import { SiteLink } from './site-link'
|
||||
import { DateTimeTooltip } from './datetime-tooltip'
|
||||
import dayjs from 'dayjs'
|
||||
import { BuyButton, ChooseCancelSelector } from './yes-no-selector'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import {
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
} from '../../common/util/format'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import { useUser } from '../hooks/use-user'
|
||||
import {
|
||||
getProbabilityAfterBet,
|
||||
getOutcomeProbability,
|
||||
calculateShares,
|
||||
calculatePayoutAfterCorrectBet,
|
||||
} from '../../common/calculate'
|
||||
import { firebaseLogin } from '../lib/firebase/users'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { useAnswers } from '../hooks/use-answers'
|
||||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { tradingAllowed } from '../lib/firebase/contracts'
|
||||
|
||||
export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
|
||||
const { contract } = props
|
||||
const { creatorId, resolution } = contract
|
||||
|
||||
const answers = useAnswers(contract.id) ?? props.answers
|
||||
const [chosenAnswer, otherAnswers] = _.partition(
|
||||
answers.filter((answer) => answer.id !== '0'),
|
||||
(answer) => answer.id === resolution
|
||||
)
|
||||
const sortedAnswers = [
|
||||
...chosenAnswer,
|
||||
..._.sortBy(
|
||||
otherAnswers,
|
||||
(answer) => -1 * getOutcomeProbability(contract.totalShares, answer.id)
|
||||
),
|
||||
]
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [resolveOption, setResolveOption] = useState<
|
||||
'CHOOSE' | 'CANCEL' | undefined
|
||||
>()
|
||||
const [answerChoice, setAnswerChoice] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (resolveOption !== 'CHOOSE' && answerChoice) setAnswerChoice(undefined)
|
||||
}, [answerChoice, resolveOption])
|
||||
|
||||
return (
|
||||
<Col className="gap-3">
|
||||
{sortedAnswers.map((answer) => (
|
||||
<AnswerItem
|
||||
key={answer.id}
|
||||
answer={answer}
|
||||
contract={contract}
|
||||
showChoice={!resolution && resolveOption === 'CHOOSE'}
|
||||
isChosen={answer.id === answerChoice}
|
||||
onChoose={() => setAnswerChoice(answer.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sortedAnswers.length === 0 ? (
|
||||
<div className="text-gray-500 p-4">No answers yet...</div>
|
||||
) : (
|
||||
<div className="text-gray-500 self-end p-4">
|
||||
None of the above:{' '}
|
||||
{formatPercent(getOutcomeProbability(contract.totalShares, '0'))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) && <CreateAnswerInput contract={contract} />}
|
||||
|
||||
{user?.id === creatorId && !resolution && (
|
||||
<AnswerResolvePanel
|
||||
contract={contract}
|
||||
resolveOption={resolveOption}
|
||||
setResolveOption={setResolveOption}
|
||||
answer={answerChoice}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function AnswerItem(props: {
|
||||
answer: Answer
|
||||
contract: Contract
|
||||
showChoice: boolean
|
||||
isChosen: boolean
|
||||
onChoose: () => void
|
||||
}) {
|
||||
const { answer, contract, showChoice, isChosen, onChoose } = props
|
||||
const { resolution, totalShares } = contract
|
||||
const { username, avatarUrl, name, createdTime, number, text } = answer
|
||||
|
||||
const createdDate = dayjs(createdTime).format('MMM D')
|
||||
const prob = getOutcomeProbability(totalShares, answer.id)
|
||||
const probPercent = formatPercent(prob)
|
||||
const wasResolvedTo = resolution === answer.id
|
||||
|
||||
const [isBetting, setIsBetting] = useState(false)
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
'p-4 sm:flex-row rounded gap-4',
|
||||
wasResolvedTo
|
||||
? 'bg-green-50 mb-8'
|
||||
: isChosen
|
||||
? 'bg-green-50'
|
||||
: 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Col className="gap-3 flex-1">
|
||||
<div className="whitespace-pre-line break-words">{text}</div>
|
||||
|
||||
<Row className="text-gray-500 text-sm gap-2 items-center">
|
||||
<SiteLink className="relative" href={`/${username}`}>
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar avatarUrl={avatarUrl} size={6} />
|
||||
<div className="truncate">{name}</div>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
|
||||
<div className="">•</div>
|
||||
|
||||
<div className="whitespace-nowrap">
|
||||
<DateTimeTooltip text="" time={contract.createdTime}>
|
||||
{createdDate}
|
||||
</DateTimeTooltip>
|
||||
</div>
|
||||
<div className="">•</div>
|
||||
<div className="text-base">#{number}</div>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
{isBetting ? (
|
||||
<AnswerBetPanel
|
||||
answer={answer}
|
||||
contract={contract}
|
||||
closePanel={() => setIsBetting(false)}
|
||||
/>
|
||||
) : (
|
||||
<Row className="self-end sm:self-start items-center gap-4">
|
||||
{!wasResolvedTo && (
|
||||
<div
|
||||
className={clsx(
|
||||
'text-2xl',
|
||||
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{probPercent}
|
||||
</div>
|
||||
)}
|
||||
{showChoice ? (
|
||||
<div className="form-control py-1">
|
||||
<label className="cursor-pointer label gap-2">
|
||||
<span className="label-text">Choose this answer</span>
|
||||
<input
|
||||
className={clsx('radio', isChosen && '!bg-green-500')}
|
||||
type="radio"
|
||||
name="opt"
|
||||
checked={isChosen}
|
||||
onChange={onChoose}
|
||||
value={answer.id}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tradingAllowed(contract) && (
|
||||
<BuyButton
|
||||
className="justify-end self-end flex-initial btn-md !px-8"
|
||||
onClick={() => {
|
||||
setIsBetting(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{wasResolvedTo && (
|
||||
<Col className="items-end">
|
||||
<div className="text-green-700 text-xl">Chosen</div>
|
||||
<div className="text-2xl text-gray-500">{probPercent}</div>
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
contract: Contract
|
||||
closePanel: () => void
|
||||
}) {
|
||||
const { answer, contract, closePanel } = props
|
||||
const { id: answerId } = answer
|
||||
|
||||
const user = useUser()
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current.focus()
|
||||
}, [])
|
||||
|
||||
async function submitBet() {
|
||||
if (!user || !betAmount) return
|
||||
|
||||
if (user.balance < betAmount) {
|
||||
setError('Insufficient balance')
|
||||
return
|
||||
}
|
||||
|
||||
setError(undefined)
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await placeBet({
|
||||
amount: betAmount,
|
||||
outcome: answerId,
|
||||
contractId: contract.id,
|
||||
}).then((r) => r.data as any)
|
||||
|
||||
console.log('placed bet. Result:', result)
|
||||
|
||||
if (result?.status === 'success') {
|
||||
setIsSubmitting(false)
|
||||
closePanel()
|
||||
} else {
|
||||
setError(result?.error || 'Error placing bet')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const betDisabled = isSubmitting || !betAmount || error
|
||||
|
||||
const initialProb = getOutcomeProbability(contract.totalShares, answer.id)
|
||||
|
||||
const resultProb = getProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
answerId,
|
||||
betAmount ?? 0
|
||||
)
|
||||
|
||||
const shares = calculateShares(contract.totalShares, betAmount ?? 0, answerId)
|
||||
|
||||
const currentPayout = betAmount
|
||||
? calculatePayoutAfterCorrectBet(contract, {
|
||||
outcome: answerId,
|
||||
amount: betAmount,
|
||||
shares,
|
||||
} as Bet)
|
||||
: 0
|
||||
|
||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
|
||||
|
||||
return (
|
||||
<Col className="items-start px-2 pb-2 pt-4 sm:pt-0">
|
||||
<Row className="self-stretch items-center justify-between">
|
||||
<div className="text-xl">Buy this answer</div>
|
||||
|
||||
<button className="btn-ghost btn-circle" onClick={closePanel}>
|
||||
<XIcon className="w-8 h-8 text-gray-500 mx-auto" aria-hidden="true" />
|
||||
</button>
|
||||
</Row>
|
||||
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
|
||||
<AmountInput
|
||||
inputClassName="w-full"
|
||||
amount={betAmount}
|
||||
onChange={setBetAmount}
|
||||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div>
|
||||
<Row>
|
||||
<div>{formatPercent(initialProb)}</div>
|
||||
<div className="mx-2">→</div>
|
||||
<div>{formatPercent(resultProb)}</div>
|
||||
</Row>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
|
||||
Payout if chosen
|
||||
<InfoTooltip
|
||||
text={`Current payout for ${formatWithCommas(
|
||||
shares
|
||||
)} / ${formatWithCommas(
|
||||
shares + contract.totalShares[answerId]
|
||||
)} shares`}
|
||||
/>
|
||||
</Row>
|
||||
<div>
|
||||
{formatMoney(currentPayout)}
|
||||
<span>(+{currentReturnPercent})</span>
|
||||
</div>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn',
|
||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
||||
isSubmitting ? 'loading' : ''
|
||||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn mt-4 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 in to trade!
|
||||
</button>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAnswerInput(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const [text, setText] = useState('')
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
||||
const [amountError, setAmountError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const canSubmit = text && betAmount && !amountError && !isSubmitting
|
||||
|
||||
const submitAnswer = async () => {
|
||||
if (canSubmit) {
|
||||
setIsSubmitting(true)
|
||||
const result = await createAnswer({
|
||||
contractId: contract.id,
|
||||
text,
|
||||
amount: betAmount,
|
||||
}).then((r) => r.data)
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (result.status === 'success') {
|
||||
setText('')
|
||||
setBetAmount(10)
|
||||
setAmountError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultProb = getProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
'new',
|
||||
betAmount ?? 0
|
||||
)
|
||||
|
||||
const shares = calculateShares(contract.totalShares, betAmount ?? 0, 'new')
|
||||
|
||||
const currentPayout = betAmount
|
||||
? calculatePayoutAfterCorrectBet(contract, {
|
||||
outcome: 'new',
|
||||
amount: betAmount,
|
||||
shares,
|
||||
} as Bet)
|
||||
: 0
|
||||
|
||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
|
||||
|
||||
return (
|
||||
<Col className="gap-4 p-4 bg-gray-50 rounded">
|
||||
<Col className="flex-1 gap-2">
|
||||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="textarea textarea-bordered w-full"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={10000}
|
||||
/>
|
||||
<div />
|
||||
<Col
|
||||
className={clsx(
|
||||
'sm:flex-row gap-4',
|
||||
text ? 'justify-between' : 'self-end'
|
||||
)}
|
||||
>
|
||||
{text && (
|
||||
<>
|
||||
<Col className="gap-2 mt-1">
|
||||
<div className="text-gray-500 text-sm">
|
||||
Ante (cannot be sold)
|
||||
</div>
|
||||
<AmountInput
|
||||
amount={betAmount}
|
||||
onChange={setBetAmount}
|
||||
error={amountError}
|
||||
setError={setAmountError}
|
||||
minimumAmount={10}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Col>
|
||||
<Col className="gap-2 mt-1">
|
||||
<div className="text-sm text-gray-500">Implied probability</div>
|
||||
<Row>
|
||||
<div>{formatPercent(0)}</div>
|
||||
<div className="mx-2">→</div>
|
||||
<div>{formatPercent(resultProb)}</div>
|
||||
</Row>
|
||||
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
|
||||
Payout if chosen
|
||||
<InfoTooltip
|
||||
text={`Current payout for ${formatWithCommas(
|
||||
shares
|
||||
)} / ${formatWithCommas(shares)} shares`}
|
||||
/>
|
||||
</Row>
|
||||
<div>
|
||||
{formatMoney(currentPayout)}
|
||||
<span>(+{currentReturnPercent})</span>
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
'btn self-end mt-2',
|
||||
canSubmit ? 'btn-outline' : 'btn-disabled',
|
||||
isSubmitting && 'loading'
|
||||
)}
|
||||
disabled={!canSubmit}
|
||||
onClick={submitAnswer}
|
||||
>
|
||||
Submit answer & bet
|
||||
</button>
|
||||
</Col>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function AnswerResolvePanel(props: {
|
||||
contract: Contract
|
||||
resolveOption: 'CHOOSE' | 'CANCEL' | undefined
|
||||
setResolveOption: (option: 'CHOOSE' | 'CANCEL' | undefined) => void
|
||||
answer: string | undefined
|
||||
}) {
|
||||
const { contract, resolveOption, setResolveOption, answer } = props
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
const onResolve = async () => {
|
||||
if (resolveOption === 'CHOOSE' && answer === undefined) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await resolveMarket({
|
||||
outcome: resolveOption === 'CHOOSE' ? (answer as string) : 'CANCEL',
|
||||
contractId: contract.id,
|
||||
}).then((r) => r.data as any)
|
||||
|
||||
console.log('resolved', `#${answer}`, 'result:', result)
|
||||
|
||||
if (result?.status !== 'success') {
|
||||
setError(result?.error || 'Error resolving market')
|
||||
}
|
||||
setResolveOption(undefined)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const resolutionButtonClass =
|
||||
resolveOption === 'CANCEL'
|
||||
? 'bg-yellow-400 hover:bg-yellow-500'
|
||||
: resolveOption === 'CHOOSE' && answer
|
||||
? 'btn-primary'
|
||||
: 'btn-disabled'
|
||||
|
||||
return (
|
||||
<Col className="gap-4 p-4 bg-gray-50 rounded">
|
||||
<div>Resolve your market</div>
|
||||
<Col className="sm:flex-row sm:items-center gap-4">
|
||||
<ChooseCancelSelector
|
||||
className="sm:!flex-row sm:items-center"
|
||||
selected={resolveOption}
|
||||
onSelect={setResolveOption}
|
||||
/>
|
||||
|
||||
<Row
|
||||
className={clsx(
|
||||
'flex-1 items-center',
|
||||
resolveOption ? 'justify-between' : 'justify-end'
|
||||
)}
|
||||
>
|
||||
{resolveOption && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setResolveOption(undefined)
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<ResolveConfirmationButton
|
||||
onResolve={onResolve}
|
||||
isSubmitting={isSubmitting}
|
||||
openModelButtonClass={resolutionButtonClass}
|
||||
submitButtonClass={resolutionButtonClass}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
{!!error && <div className="text-red-500">{error}</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -49,6 +49,7 @@ export function BetPanel(props: {
|
|||
}, [])
|
||||
|
||||
const { contract, className, title, selected, onBetSuccess } = props
|
||||
const { totalShares, phantomShares } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
|
@ -108,11 +109,12 @@ export function BetPanel(props: {
|
|||
|
||||
const initialProb = getProbability(contract.totalShares)
|
||||
|
||||
const resultProb = getProbabilityAfterBet(
|
||||
const outcomeProb = getProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
betChoice || 'YES',
|
||||
betAmount ?? 0
|
||||
)
|
||||
const resultProb = betChoice === 'NO' ? 1 - outcomeProb : outcomeProb
|
||||
|
||||
const shares = calculateShares(
|
||||
contract.totalShares,
|
||||
|
@ -179,8 +181,8 @@ export function BetPanel(props: {
|
|||
shares
|
||||
)} / ${formatWithCommas(
|
||||
shares +
|
||||
contract.totalShares[betChoice] -
|
||||
contract.phantomShares[betChoice]
|
||||
totalShares[betChoice] -
|
||||
(phantomShares ? phantomShares[betChoice] : 0)
|
||||
)} ${betChoice} shares`}
|
||||
/>
|
||||
</Row>
|
||||
|
|
|
@ -18,12 +18,11 @@ import {
|
|||
Contract,
|
||||
getContractFromId,
|
||||
contractPath,
|
||||
contractMetrics,
|
||||
getBinaryProbPercent,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Row } from './layout/row'
|
||||
import { UserLink } from './user-page'
|
||||
import {
|
||||
calculateCancelPayout,
|
||||
calculatePayout,
|
||||
calculateSaleAmount,
|
||||
getProbability,
|
||||
|
@ -32,6 +31,7 @@ import {
|
|||
import { sellBet } from '../lib/firebase/api-call'
|
||||
import { ConfirmationButton } from './confirmation-button'
|
||||
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
type BetSort = 'newest' | 'profit' | 'resolved' | 'value'
|
||||
|
||||
|
@ -50,7 +50,7 @@ export function BetsList(props: { user: User }) {
|
|||
let disposed = false
|
||||
Promise.all(contractIds.map((id) => getContractFromId(id))).then(
|
||||
(contracts) => {
|
||||
if (!disposed) setContracts(contracts.filter(Boolean) as Contract[])
|
||||
if (!disposed) setContracts(filterDefined(contracts))
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -183,10 +183,13 @@ export function BetsList(props: { user: User }) {
|
|||
|
||||
function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { bets, contract } = props
|
||||
const { resolution } = contract
|
||||
const { resolution, outcomeType } = contract
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const probPercent = getBinaryProbPercent(contract)
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
|
@ -216,6 +219,8 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
|
|||
</Row>
|
||||
|
||||
<Row className="items-center gap-2 text-sm text-gray-500">
|
||||
{isBinary && (
|
||||
<>
|
||||
{resolution ? (
|
||||
<div>
|
||||
Resolved <OutcomeLabel outcome={resolution} />
|
||||
|
@ -224,6 +229,8 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
|
|||
<div className="text-primary text-lg">{probPercent}</div>
|
||||
)}
|
||||
<div>•</div>
|
||||
</>
|
||||
)}
|
||||
<UserLink
|
||||
name={contract.creatorName}
|
||||
username={contract.creatorUsername}
|
||||
|
@ -266,8 +273,8 @@ export function MyBetsSummary(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { bets, contract, onlyMKT, className } = props
|
||||
const { resolution } = contract
|
||||
calculateCancelPayout
|
||||
const { resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount)
|
||||
|
@ -362,10 +369,16 @@ export function MyBetsSummary(props: {
|
|||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
{isBinary ? (
|
||||
<>
|
||||
Payout at{' '}
|
||||
<span className="text-blue-400">
|
||||
{formatPercent(getProbability(contract.totalShares))}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>Current payout</>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(marketWinnings)}
|
||||
|
|
|
@ -51,3 +51,36 @@ export function ConfirmationButton(props: {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResolveConfirmationButton(props: {
|
||||
onResolve: () => void
|
||||
isSubmitting: boolean
|
||||
openModelButtonClass?: string
|
||||
submitButtonClass?: string
|
||||
}) {
|
||||
const { onResolve, isSubmitting, openModelButtonClass, submitButtonClass } =
|
||||
props
|
||||
return (
|
||||
<ConfirmationButton
|
||||
id="resolution-modal"
|
||||
openModelBtn={{
|
||||
className: clsx(
|
||||
'border-none self-start',
|
||||
openModelButtonClass,
|
||||
isSubmitting && 'btn-disabled loading'
|
||||
),
|
||||
label: 'Resolve',
|
||||
}}
|
||||
cancelBtn={{
|
||||
label: 'Back',
|
||||
}}
|
||||
submitBtn={{
|
||||
label: 'Resolve',
|
||||
className: clsx('border-none', submitButtonClass),
|
||||
}}
|
||||
onSubmit={onResolve}
|
||||
>
|
||||
<p>Are you sure you want to resolve this market?</p>
|
||||
</ConfirmationButton>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
Contract,
|
||||
contractMetrics,
|
||||
contractPath,
|
||||
getBinaryProbPercent,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Col } from './layout/col'
|
||||
import dayjs from 'dayjs'
|
||||
|
@ -24,8 +25,7 @@ export function ContractCard(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime, className } = props
|
||||
const { question, resolution } = contract
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
const { question } = contract
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -66,27 +66,29 @@ export function ResolutionOrChance(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { contract, large, className } = props
|
||||
const { resolution } = contract
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
const { resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const marketClosed = (contract.closeTime || Infinity) < Date.now()
|
||||
|
||||
const resolutionColor = {
|
||||
const resolutionColor =
|
||||
{
|
||||
YES: 'text-primary',
|
||||
NO: 'text-red-400',
|
||||
MKT: 'text-blue-400',
|
||||
CANCEL: 'text-yellow-400',
|
||||
'': '', // Empty if unresolved
|
||||
}[resolution || '']
|
||||
}[resolution || ''] ?? 'text-primary'
|
||||
|
||||
const probColor = marketClosed ? 'text-gray-400' : 'text-primary'
|
||||
|
||||
const resolutionText = {
|
||||
const resolutionText =
|
||||
{
|
||||
YES: 'YES',
|
||||
NO: 'NO',
|
||||
MKT: probPercent,
|
||||
MKT: getBinaryProbPercent(contract),
|
||||
CANCEL: 'N/A',
|
||||
'': '',
|
||||
}[resolution || '']
|
||||
}[resolution || ''] ?? `#${resolution}`
|
||||
|
||||
return (
|
||||
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
|
||||
|
@ -100,12 +102,14 @@ export function ResolutionOrChance(props: {
|
|||
<div className={resolutionColor}>{resolutionText}</div>
|
||||
</>
|
||||
) : (
|
||||
isBinary && (
|
||||
<>
|
||||
<div className={probColor}>{probPercent}</div>
|
||||
<div className={probColor}>{getBinaryProbPercent(contract)}</div>
|
||||
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
|
||||
chance
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
// From https://tailwindui.com/components/application-ui/lists/feeds
|
||||
import { useState } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import _ from 'lodash'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
DotsVerticalIcon,
|
||||
LockClosedIcon,
|
||||
StarIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import dayjs from 'dayjs'
|
||||
import clsx from 'clsx'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { OutcomeLabel } from './outcome-label'
|
||||
import {
|
||||
|
@ -34,11 +34,9 @@ import { Col } from './layout/col'
|
|||
import { UserLink } from './user-page'
|
||||
import { DateTimeTooltip } from './datetime-tooltip'
|
||||
import { useBets } from '../hooks/use-bets'
|
||||
import { Bet, withoutAnteBets } from '../lib/firebase/bets'
|
||||
import { Bet } from '../lib/firebase/bets'
|
||||
import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
|
||||
import { JoinSpans } from './join-spans'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { outcome } from '../../common/contract'
|
||||
import { fromNow } from '../lib/util/time'
|
||||
import BetRow from './bet-row'
|
||||
import { parseTags } from '../../common/util/parse'
|
||||
|
@ -204,7 +202,7 @@ function EditContract(props: {
|
|||
) : (
|
||||
<Row>
|
||||
<button
|
||||
className="btn btn-neutral btn-outline btn-sm mt-4"
|
||||
className="btn btn-neutral btn-outline btn-xs mt-4"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{props.buttonText}
|
||||
|
@ -302,9 +300,10 @@ function TruncatedComment(props: {
|
|||
|
||||
function FeedQuestion(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { creatorName, creatorUsername, createdTime, question, resolution } =
|
||||
const { creatorName, creatorUsername, question, resolution, outcomeType } =
|
||||
contract
|
||||
const { probPercent, truePool } = contractMetrics(contract)
|
||||
const { truePool } = contractMetrics(contract)
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
// Currently hidden on mobile; ideally we'd fit this in somewhere.
|
||||
const closeMessage =
|
||||
|
@ -340,7 +339,9 @@ function FeedQuestion(props: { contract: Contract }) {
|
|||
>
|
||||
{question}
|
||||
</SiteLink>
|
||||
{(isBinary || resolution) && (
|
||||
<ResolutionOrChance className="items-center" contract={contract} />
|
||||
)}
|
||||
</Col>
|
||||
<TruncatedComment
|
||||
comment={contract.description}
|
||||
|
@ -379,7 +380,7 @@ function FeedDescription(props: { contract: Contract }) {
|
|||
)
|
||||
}
|
||||
|
||||
function OutcomeIcon(props: { outcome?: outcome }) {
|
||||
function OutcomeIcon(props: { outcome?: string }) {
|
||||
const { outcome } = props
|
||||
switch (outcome) {
|
||||
case 'YES':
|
||||
|
@ -387,8 +388,9 @@ function OutcomeIcon(props: { outcome?: outcome }) {
|
|||
case 'NO':
|
||||
return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
case 'CANCEL':
|
||||
default:
|
||||
return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
default:
|
||||
return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -536,7 +538,7 @@ function groupBets(
|
|||
return items as ActivityItem[]
|
||||
}
|
||||
|
||||
function BetGroupSpan(props: { bets: Bet[]; outcome: 'YES' | 'NO' }) {
|
||||
function BetGroupSpan(props: { bets: Bet[]; outcome: string }) {
|
||||
const { bets, outcome } = props
|
||||
|
||||
const numberTraders = _.uniqBy(bets, (b) => b.userId).length
|
||||
|
@ -562,7 +564,8 @@ function FeedBetGroup(props: { activityItem: any }) {
|
|||
const { activityItem } = props
|
||||
const bets: Bet[] = activityItem.bets
|
||||
|
||||
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
|
||||
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
|
||||
const outcomes = Object.keys(betGroups)
|
||||
|
||||
// Use the time of the last bet for the entire group
|
||||
const createdTime = bets[bets.length - 1].createdTime
|
||||
|
@ -578,9 +581,12 @@ function FeedBetGroup(props: { activityItem: any }) {
|
|||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-gray-500">
|
||||
{yesBets.length > 0 && <BetGroupSpan outcome="YES" bets={yesBets} />}
|
||||
{yesBets.length > 0 && noBets.length > 0 && <br />}
|
||||
{noBets.length > 0 && <BetGroupSpan outcome="NO" bets={noBets} />}
|
||||
{outcomes.map((outcome, index) => (
|
||||
<Fragment key={outcome}>
|
||||
<BetGroupSpan outcome={outcome} bets={betGroups[outcome]} />
|
||||
{index !== outcomes.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
<Timestamp time={createdTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -638,12 +644,16 @@ export function ContractFeed(props: {
|
|||
betRowClassName?: string
|
||||
}) {
|
||||
const { contract, feedType, betRowClassName } = props
|
||||
const { id } = contract
|
||||
const { id, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const user = useUser()
|
||||
|
||||
let bets = useBets(id) ?? props.bets
|
||||
bets = withoutAnteBets(contract, bets)
|
||||
let bets = useBets(contract.id) ?? props.bets
|
||||
bets = isBinary
|
||||
? bets.filter((bet) => !bet.isAnte)
|
||||
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
|
||||
|
||||
const comments = useComments(id) ?? props.comments
|
||||
|
||||
|
@ -711,7 +721,7 @@ export function ContractFeed(props: {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{tradingAllowed(contract) && (
|
||||
{isBinary && tradingAllowed(contract) && (
|
||||
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
deleteContract,
|
||||
contractPath,
|
||||
tradingAllowed,
|
||||
getBinaryProbPercent,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Col } from './layout/col'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
@ -28,40 +28,36 @@ export const ContractOverview = (props: {
|
|||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
folds: Fold[]
|
||||
children?: any
|
||||
className?: string
|
||||
}) => {
|
||||
const { contract, bets, comments, folds, className } = props
|
||||
const { resolution, creatorId, creatorName } = contract
|
||||
const { probPercent, truePool } = contractMetrics(contract)
|
||||
const { contract, bets, comments, folds, children, className } = props
|
||||
const { question, resolution, creatorId, outcomeType } = contract
|
||||
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const tweetQuestion = isCreator
|
||||
? contract.question
|
||||
: `${creatorName}: ${contract.question}`
|
||||
const tweetDescription = resolution
|
||||
? `Resolved ${resolution}!`
|
||||
: `Currently ${probPercent} chance, place your bets here:`
|
||||
const url = `https://manifold.markets${contractPath(contract)}`
|
||||
const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
|
||||
const tweetText = getTweetText(contract, isCreator)
|
||||
|
||||
return (
|
||||
<Col className={clsx('mb-6', className)}>
|
||||
<Row className="justify-between gap-4 px-2">
|
||||
<Col className="gap-4">
|
||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||
<Linkify text={contract.question} />
|
||||
<Linkify text={question} />
|
||||
</div>
|
||||
|
||||
<Row className="items-center justify-between gap-4">
|
||||
{(isBinary || resolution) && (
|
||||
<ResolutionOrChance
|
||||
className="md:hidden"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
{isBinary && tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract}
|
||||
className="md:hidden"
|
||||
|
@ -73,16 +69,24 @@ export const ContractOverview = (props: {
|
|||
<ContractDetails contract={contract} />
|
||||
</Col>
|
||||
|
||||
{(isBinary || resolution) && (
|
||||
<Col className="hidden items-end justify-between md:flex">
|
||||
<ResolutionOrChance className="items-end" contract={contract} large />
|
||||
<ResolutionOrChance
|
||||
className="items-end"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}
|
||||
|
||||
<Row className="mt-6 ml-4 hidden items-center justify-between gap-4 sm:flex">
|
||||
{children}
|
||||
|
||||
<Row className="mt-6 hidden items-center justify-between gap-4 sm:flex">
|
||||
{folds.length === 0 ? (
|
||||
<TagsInput className={clsx('mx-4')} contract={contract} />
|
||||
) : (
|
||||
|
@ -91,7 +95,7 @@ export const ContractOverview = (props: {
|
|||
<TweetButton tweetText={tweetText} />
|
||||
</Row>
|
||||
|
||||
<Col className="mt-6 ml-4 gap-4 sm:hidden">
|
||||
<Col className="mt-6 gap-4 sm:hidden">
|
||||
<TweetButton className="self-end" tweetText={tweetText} />
|
||||
{folds.length === 0 ? (
|
||||
<TagsInput contract={contract} />
|
||||
|
@ -101,15 +105,12 @@ export const ContractOverview = (props: {
|
|||
</Col>
|
||||
|
||||
{folds.length > 0 && (
|
||||
<RevealableTagsInput className="mx-4 mt-4" contract={contract} />
|
||||
<RevealableTagsInput className="mt-4" contract={contract} />
|
||||
)}
|
||||
|
||||
<Spacer h={12} />
|
||||
|
||||
{/* Show a delete button for contracts without any trading */}
|
||||
{isCreator && truePool === 0 && (
|
||||
{isCreator && bets.length === 0 && (
|
||||
<>
|
||||
<Spacer h={8} />
|
||||
<button
|
||||
className="btn btn-xs btn-error btn-outline mt-1 max-w-fit self-end"
|
||||
onClick={async (e) => {
|
||||
|
@ -123,6 +124,8 @@ export const ContractOverview = (props: {
|
|||
</>
|
||||
)}
|
||||
|
||||
<Spacer h={12} />
|
||||
|
||||
<ContractFeed
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
|
@ -133,3 +136,22 @@ export const ContractOverview = (props: {
|
|||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract, isCreator: boolean) => {
|
||||
const { question, creatorName, resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const tweetQuestion = isCreator
|
||||
? question
|
||||
: `${question} Asked by ${creatorName}.`
|
||||
const tweetDescription = resolution
|
||||
? `Resolved ${resolution}!`
|
||||
: isBinary
|
||||
? `Currently ${getBinaryProbPercent(
|
||||
contract
|
||||
)} chance, place your bets here:`
|
||||
: `Submit your own answer:`
|
||||
const url = `https://manifold.markets${contractPath(contract)}`
|
||||
|
||||
return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
|
||||
}
|
||||
|
|
|
@ -13,7 +13,9 @@ export function ContractProbGraph(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
const bets = useBetsWithoutAntes(contract, props.bets)
|
||||
|
||||
const startProb = getProbability(phantomShares)
|
||||
const startProb = getProbability(
|
||||
phantomShares as { [outcome: string]: number }
|
||||
)
|
||||
|
||||
const times = bets
|
||||
? [contract.createdTime, ...bets.map((bet) => bet.createdTime)].map(
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
export function OutcomeLabel(props: {
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string
|
||||
}) {
|
||||
const { outcome } = props
|
||||
|
||||
if (outcome === 'YES') return <YesLabel />
|
||||
if (outcome === 'NO') return <NoLabel />
|
||||
if (outcome === 'MKT') return <ProbLabel />
|
||||
return <CancelLabel />
|
||||
if (outcome === 'CANCEL') return <CancelLabel />
|
||||
return <AnswerNumberLabel number={outcome} />
|
||||
}
|
||||
|
||||
export function YesLabel() {
|
||||
|
@ -24,3 +25,7 @@ export function CancelLabel() {
|
|||
export function ProbLabel() {
|
||||
return <span className="text-blue-400">PROB</span>
|
||||
}
|
||||
|
||||
export function AnswerNumberLabel(props: { number: string }) {
|
||||
return <span className="text-primary">#{props.number}</span>
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Title } from './title'
|
|||
import { User } from '../lib/firebase/users'
|
||||
import { YesNoCancelSelector } from './yes-no-selector'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ConfirmationButton as ConfirmationButton } from './confirmation-button'
|
||||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { resolveMarket } from '../lib/firebase/api-call'
|
||||
import { ProbabilitySelector } from './probability-selector'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
|
@ -20,7 +20,7 @@ export function ResolutionPanel(props: {
|
|||
}) {
|
||||
useEffect(() => {
|
||||
// warm up cloud function
|
||||
resolveMarket({}).catch()
|
||||
resolveMarket({} as any).catch()
|
||||
}, [])
|
||||
|
||||
const { contract, className } = props
|
||||
|
@ -35,6 +35,8 @@ export function ResolutionPanel(props: {
|
|||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
const resolve = async () => {
|
||||
if (!outcome) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await resolveMarket({
|
||||
|
@ -114,27 +116,12 @@ export function ResolutionPanel(props: {
|
|||
|
||||
{!!error && <div className="text-red-500">{error}</div>}
|
||||
|
||||
<ConfirmationButton
|
||||
id="resolution-modal"
|
||||
openModelBtn={{
|
||||
className: clsx(
|
||||
'border-none self-start mt-2 w-full',
|
||||
submitButtonClass,
|
||||
isSubmitting && 'btn-disabled loading'
|
||||
),
|
||||
label: 'Resolve',
|
||||
}}
|
||||
cancelBtn={{
|
||||
label: 'Back',
|
||||
}}
|
||||
submitBtn={{
|
||||
label: 'Resolve',
|
||||
className: submitButtonClass,
|
||||
}}
|
||||
onSubmit={resolve}
|
||||
>
|
||||
<p>Are you sure you want to resolve this market?</p>
|
||||
</ConfirmationButton>
|
||||
<ResolveConfirmationButton
|
||||
onResolve={resolve}
|
||||
isSubmitting={isSubmitting}
|
||||
openModelButtonClass={clsx('w-full mt-2', submitButtonClass)}
|
||||
submitButtonClass={submitButtonClass}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -90,6 +90,37 @@ export function YesNoCancelSelector(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function ChooseCancelSelector(props: {
|
||||
selected: 'CHOOSE' | 'CANCEL' | undefined
|
||||
onSelect: (selected: 'CHOOSE' | '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 === 'CHOOSE' ? 'green' : 'gray'}
|
||||
onClick={() => onSelect('CHOOSE')}
|
||||
className={clsx('whitespace-nowrap', btnClassName)}
|
||||
>
|
||||
Choose an answer
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={selected === 'CANCEL' ? 'yellow' : 'gray'}
|
||||
onClick={() => onSelect('CANCEL')}
|
||||
className={clsx(btnClassName, '')}
|
||||
>
|
||||
N/A
|
||||
</Button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
const fundAmounts = [500, 1000, 2500, 10000]
|
||||
|
||||
export function FundsSelector(props: {
|
||||
|
@ -117,6 +148,15 @@ export function FundsSelector(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function BuyButton(props: { className?: string; onClick?: () => void }) {
|
||||
const { className, onClick } = props
|
||||
return (
|
||||
<Button className={className} onClick={onClick} color="green">
|
||||
BUY
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(props: {
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
|
@ -129,7 +169,7 @@ function Button(props: {
|
|||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 text-sm font-medium shadow-sm',
|
||||
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm',
|
||||
color === 'green' && 'btn-primary text-white',
|
||||
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
|
||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||
|
|
13
web/hooks/use-answers.ts
Normal file
13
web/hooks/use-answers.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { listenForAnswers } from '../lib/firebase/answers'
|
||||
|
||||
export const useAnswers = (contractId: string) => {
|
||||
const [answers, setAnswers] = useState<Answer[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForAnswers(contractId, setAnswers)
|
||||
}, [contractId])
|
||||
|
||||
return answers
|
||||
}
|
28
web/lib/firebase/answers.ts
Normal file
28
web/lib/firebase/answers.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { collection } from 'firebase/firestore'
|
||||
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
import { Answer } from '../../../common/answer'
|
||||
|
||||
function getAnswersCollection(contractId: string) {
|
||||
return collection(db, 'contracts', contractId, 'answers')
|
||||
}
|
||||
|
||||
export async function listAllAnswers(contractId: string) {
|
||||
const answers = await getValues<Answer>(getAnswersCollection(contractId))
|
||||
answers.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
return answers
|
||||
}
|
||||
|
||||
export function listenForAnswers(
|
||||
contractId: string,
|
||||
setAnswers: (answers: Answer[]) => void
|
||||
) {
|
||||
return listenForValues<Answer>(
|
||||
getAnswersCollection(contractId),
|
||||
(answers) => {
|
||||
answers.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
setAnswers(answers)
|
||||
}
|
||||
)
|
||||
}
|
|
@ -18,7 +18,24 @@ export const createFold = cloudFunction<
|
|||
|
||||
export const placeBet = cloudFunction('placeBet')
|
||||
|
||||
export const resolveMarket = cloudFunction('resolveMarket')
|
||||
export const createAnswer = cloudFunction<
|
||||
{ contractId: string; text: string; amount: number },
|
||||
{
|
||||
status: 'error' | 'success'
|
||||
message?: string
|
||||
answerId?: string
|
||||
betId?: string
|
||||
}
|
||||
>('createAnswer')
|
||||
|
||||
export const resolveMarket = cloudFunction<
|
||||
{
|
||||
outcome: string
|
||||
contractId: string
|
||||
probabilityInt?: number
|
||||
},
|
||||
{ status: 'error' | 'success'; message?: string }
|
||||
>('resolveMarket')
|
||||
|
||||
export const sellBet = cloudFunction('sellBet')
|
||||
|
||||
|
|
|
@ -27,21 +27,9 @@ export function contractPath(contract: Contract) {
|
|||
}
|
||||
|
||||
export function contractMetrics(contract: Contract) {
|
||||
const {
|
||||
pool,
|
||||
phantomShares,
|
||||
totalShares,
|
||||
createdTime,
|
||||
resolutionTime,
|
||||
isResolved,
|
||||
resolutionProbability,
|
||||
} = contract
|
||||
const { pool, createdTime, resolutionTime, isResolved } = contract
|
||||
|
||||
const truePool = pool.YES + pool.NO
|
||||
const prob = resolutionProbability ?? getProbability(totalShares)
|
||||
const probPercent = Math.round(prob * 100) + '%'
|
||||
|
||||
const startProb = getProbability(phantomShares)
|
||||
const truePool = _.sum(Object.values(pool))
|
||||
|
||||
const createdDate = dayjs(createdTime).format('MMM D')
|
||||
|
||||
|
@ -49,7 +37,16 @@ export function contractMetrics(contract: Contract) {
|
|||
? dayjs(resolutionTime).format('MMM D')
|
||||
: undefined
|
||||
|
||||
return { truePool, probPercent, startProb, createdDate, resolvedDate }
|
||||
return { truePool, createdDate, resolvedDate }
|
||||
}
|
||||
|
||||
export function getBinaryProbPercent(contract: Contract) {
|
||||
const { totalShares, resolutionProbability } = contract
|
||||
|
||||
const prob = resolutionProbability ?? getProbability(totalShares)
|
||||
const probPercent = Math.round(prob * 100) + '%'
|
||||
|
||||
return probPercent
|
||||
}
|
||||
|
||||
export function tradingAllowed(contract: Contract) {
|
||||
|
|
|
@ -12,10 +12,10 @@ import { Title } from '../../components/title'
|
|||
import { Spacer } from '../../components/layout/spacer'
|
||||
import { User } from '../../lib/firebase/users'
|
||||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
getContractFromSlug,
|
||||
tradingAllowed,
|
||||
getBinaryProbPercent,
|
||||
} from '../../lib/firebase/contracts'
|
||||
import { SEO } from '../../components/SEO'
|
||||
import { Page } from '../../components/page'
|
||||
|
@ -26,6 +26,9 @@ import Custom404 from '../404'
|
|||
import { getFoldsByTags } from '../../lib/firebase/folds'
|
||||
import { Fold } from '../../../common/fold'
|
||||
import { useFoldsWithTags } from '../../hooks/use-fold'
|
||||
import { listAllAnswers } from '../../lib/firebase/answers'
|
||||
import { Answer } from '../../../common/answer'
|
||||
import { AnswersPanel } from '../../components/answers-panel'
|
||||
|
||||
export async function getStaticProps(props: {
|
||||
params: { username: string; contractSlug: string }
|
||||
|
@ -36,9 +39,12 @@ export async function getStaticProps(props: {
|
|||
|
||||
const foldsPromise = getFoldsByTags(contract?.tags ?? [])
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
const [bets, comments, answers] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
contractId && contract.outcomeType === 'FREE_RESPONSE'
|
||||
? listAllAnswers(contractId)
|
||||
: [],
|
||||
])
|
||||
|
||||
const folds = await foldsPromise
|
||||
|
@ -50,6 +56,7 @@ export async function getStaticProps(props: {
|
|||
slug: contractSlug,
|
||||
bets,
|
||||
comments,
|
||||
answers,
|
||||
folds,
|
||||
},
|
||||
|
||||
|
@ -66,6 +73,7 @@ export default function ContractPage(props: {
|
|||
username: string
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers: Answer[]
|
||||
slug: string
|
||||
folds: Fold[]
|
||||
}) {
|
||||
|
@ -82,33 +90,27 @@ export default function ContractPage(props: {
|
|||
return <Custom404 />
|
||||
}
|
||||
|
||||
const { creatorId, isResolved, resolution, question } = contract
|
||||
const { creatorId, isResolved, question, outcomeType } = contract
|
||||
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const allowTrade = tradingAllowed(contract)
|
||||
const allowResolve = !isResolved && isCreator && !!user
|
||||
const hasSidePanel = isBinary && (allowTrade || allowResolve)
|
||||
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${contract.description}`
|
||||
: `${probPercent} chance. ${contract.description}`
|
||||
|
||||
const ogCardProps = {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName: contract.creatorName,
|
||||
creatorUsername: contract.creatorUsername,
|
||||
}
|
||||
// TODO(James): Create SEO props for non-binary contracts.
|
||||
const ogCardProps = isBinary ? getOpenGraphProps(contract) : undefined
|
||||
|
||||
return (
|
||||
<Page wide={allowTrade || allowResolve}>
|
||||
<Page wide={hasSidePanel}>
|
||||
{ogCardProps && (
|
||||
<SEO
|
||||
title={question}
|
||||
description={description}
|
||||
description={ogCardProps.description}
|
||||
url={`/${props.username}/${props.slug}`}
|
||||
ogCardProps={ogCardProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Col className="w-full justify-between md:flex-row">
|
||||
<div className="flex-[3] rounded border-0 border-gray-100 bg-white px-2 py-6 md:px-6 md:py-8">
|
||||
|
@ -117,11 +119,24 @@ export default function ContractPage(props: {
|
|||
bets={bets ?? []}
|
||||
comments={comments ?? []}
|
||||
folds={folds}
|
||||
>
|
||||
{contract.outcomeType === 'FREE_RESPONSE' && (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<AnswersPanel
|
||||
contract={contract as any}
|
||||
answers={props.answers}
|
||||
/>
|
||||
<BetsSection contract={contract} user={user ?? null} />
|
||||
<Spacer h={4} />
|
||||
<div className="divider before:bg-gray-300 after:bg-gray-300" />
|
||||
</>
|
||||
)}
|
||||
</ContractOverview>
|
||||
|
||||
<BetsSection contract={contract} user={user ?? null} bets={bets} />
|
||||
</div>
|
||||
|
||||
{(allowTrade || allowResolve) && (
|
||||
{hasSidePanel && (
|
||||
<>
|
||||
<div className="md:ml-6" />
|
||||
|
||||
|
@ -140,11 +155,14 @@ export default function ContractPage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function BetsSection(props: { contract: Contract; user: User | null }) {
|
||||
function BetsSection(props: {
|
||||
contract: Contract
|
||||
user: User | null
|
||||
bets: Bet[]
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const bets = useBets(contract.id)
|
||||
|
||||
if (!bets || bets.length === 0) return <></>
|
||||
const isBinary = contract.outcomeType === 'BINARY'
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
|
||||
// Decending creation time.
|
||||
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
|
||||
|
@ -156,10 +174,32 @@ function BetsSection(props: { contract: Contract; user: User | null }) {
|
|||
return (
|
||||
<div>
|
||||
<Title className="px-2" text="Your trades" />
|
||||
{isBinary && (
|
||||
<>
|
||||
<MyBetsSummary className="px-2" contract={contract} bets={userBets} />
|
||||
<Spacer h={6} />
|
||||
</>
|
||||
)}
|
||||
<ContractBetsTable contract={contract} bets={userBets} />
|
||||
<Spacer h={12} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getOpenGraphProps = (contract: Contract) => {
|
||||
const { resolution, question, creatorName, creatorUsername } = contract
|
||||
const probPercent = getBinaryProbPercent(contract)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${contract.description}`
|
||||
: `${probPercent} chance. ${contract.description}`
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName: creatorName,
|
||||
creatorUsername: creatorUsername,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,8 @@ import { Title } from '../components/title'
|
|||
import { ProbabilitySelector } from '../components/probability-selector'
|
||||
import { parseWordsAsTags } from '../../common/util/parse'
|
||||
import { TagsList } from '../components/tags-list'
|
||||
import { Row } from '../components/layout/row'
|
||||
import { outcomeType } from '../../common/contract'
|
||||
|
||||
export default function Create() {
|
||||
const [question, setQuestion] = useState('')
|
||||
|
@ -61,6 +63,7 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
createContract({}).catch() // warm up function
|
||||
}, [])
|
||||
|
||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||
const [initialProb, setInitialProb] = useState(50)
|
||||
const [description, setDescription] = useState('')
|
||||
const [tagText, setTagText] = useState<string>(tag ?? '')
|
||||
|
@ -105,6 +108,7 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
|
||||
const result: any = await createContract({
|
||||
question,
|
||||
outcomeType,
|
||||
description,
|
||||
initialProb,
|
||||
ante,
|
||||
|
@ -120,14 +124,46 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
await router.push(contractPath(result.contract as Contract))
|
||||
}
|
||||
|
||||
const descriptionPlaceholder = `e.g. This market resolves to "YES" if, two weeks after closing, the...`
|
||||
const descriptionPlaceholder =
|
||||
outcomeType === 'BINARY'
|
||||
? `e.g. This market resolves to "YES" if, two weeks after closing, the...`
|
||||
: `e.g. I will choose the answer according to...`
|
||||
|
||||
if (!creator) return <></>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="label">
|
||||
<span className="mb-1">Answer type</span>
|
||||
</label>
|
||||
<Row className="form-control gap-2">
|
||||
<label className="cursor-pointer label gap-2">
|
||||
<input
|
||||
className="radio"
|
||||
type="radio"
|
||||
name="opt"
|
||||
checked={outcomeType === 'BINARY'}
|
||||
value="BINARY"
|
||||
onChange={() => setOutcomeType('BINARY')}
|
||||
/>
|
||||
<span className="label-text">Yes / No</span>
|
||||
</label>
|
||||
|
||||
<label className="cursor-pointer label gap-2">
|
||||
<input
|
||||
className="radio"
|
||||
type="radio"
|
||||
name="opt"
|
||||
checked={outcomeType === 'FREE_RESPONSE'}
|
||||
value="FREE_RESPONSE"
|
||||
onChange={() => setOutcomeType('FREE_RESPONSE')}
|
||||
/>
|
||||
<span className="label-text">Free response</span>
|
||||
</label>
|
||||
</Row>
|
||||
<Spacer h={4} />
|
||||
|
||||
{outcomeType === 'BINARY' && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="mb-1">Initial probability</span>
|
||||
|
@ -138,6 +174,7 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
setProbabilityInt={setInitialProb}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import dayjs from 'dayjs'
|
|||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { parseWordsAsTags } from '../../common/util/parse'
|
||||
import { AmountInput } from '../components/amount-input'
|
||||
import { InfoTooltip } from '../components/info-tooltip'
|
||||
|
@ -15,11 +16,7 @@ import { Page } from '../components/page'
|
|||
import { Title } from '../components/title'
|
||||
import { useUser } from '../hooks/use-user'
|
||||
import { createContract } from '../lib/firebase/api-call'
|
||||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
contractPath,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Contract, contractPath } from '../lib/firebase/contracts'
|
||||
|
||||
type Prediction = {
|
||||
question: string
|
||||
|
@ -29,7 +26,7 @@ type Prediction = {
|
|||
}
|
||||
|
||||
function toPrediction(contract: Contract): Prediction {
|
||||
const { startProb } = contractMetrics(contract)
|
||||
const startProb = getProbability(contract.totalShares)
|
||||
return {
|
||||
question: contract.question,
|
||||
description: contract.description,
|
||||
|
|
BIN
web/public/logo-white-inside.png
Normal file
BIN
web/public/logo-white-inside.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 139 KiB |
Loading…
Reference in New Issue
Block a user