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:
James Grugett 2022-02-17 17:00:19 -06:00 committed by GitHub
parent d3fdd4cd1f
commit b2501d8145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1524 additions and 400 deletions

31
common/answer.ts Normal file
View 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',
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

@ -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,22 +89,36 @@ export const createContract = functions
await contractRef.create(contract)
if (ante) {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
if (outcomeType === 'BINARY') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets(
creator,
contract,
yesBetDoc.id,
noBetDoc.id
)
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
const { yesBet, noBet } = getAnteBets(
creator,
contract,
yesBetDoc.id,
noBetDoc.id
)
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 }

View File

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

View File

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

View File

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

View File

@ -25,10 +25,25 @@ export const resolveMarket = functions
const { outcome, contractId, probabilityInt } = data
if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
return { status: 'error', message: 'Invalid outcome' }
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(

View File

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

View File

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

View 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)}
&nbsp; <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)}
&nbsp; <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>
)
}

View File

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

View File

@ -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,14 +219,18 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
</Row>
<Row className="items-center gap-2 text-sm text-gray-500">
{resolution ? (
<div>
Resolved <OutcomeLabel outcome={resolution} />
</div>
) : (
<div className="text-primary text-lg">{probPercent}</div>
{isBinary && (
<>
{resolution ? (
<div>
Resolved <OutcomeLabel outcome={resolution} />
</div>
) : (
<div className="text-primary text-lg">{probPercent}</div>
)}
<div></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">
Payout at{' '}
<span className="text-blue-400">
{formatPercent(getProbability(contract.totalShares))}
</span>
{isBinary ? (
<>
Payout at{' '}
<span className="text-blue-400">
{formatPercent(getProbability(contract.totalShares))}
</span>
</>
) : (
<>Current payout</>
)}
</div>
<div className="whitespace-nowrap">
{formatMoney(marketWinnings)}

View File

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

View File

@ -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 = {
YES: 'text-primary',
NO: 'text-red-400',
MKT: 'text-blue-400',
CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved
}[resolution || '']
const resolutionColor =
{
YES: 'text-primary',
NO: 'text-red-400',
MKT: 'text-blue-400',
CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved
}[resolution || ''] ?? 'text-primary'
const probColor = marketClosed ? 'text-gray-400' : 'text-primary'
const resolutionText = {
YES: 'YES',
NO: 'NO',
MKT: probPercent,
CANCEL: 'N/A',
'': '',
}[resolution || '']
const resolutionText =
{
YES: 'YES',
NO: 'NO',
MKT: getBinaryProbPercent(contract),
CANCEL: 'N/A',
'': '',
}[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>
</>
) : (
<>
<div className={probColor}>{probPercent}</div>
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
chance
</div>
</>
isBinary && (
<>
<div className={probColor}>{getBinaryProbPercent(contract)}</div>
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
chance
</div>
</>
)
)}
</Col>
)

View File

@ -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>
<ResolutionOrChance className="items-center" contract={contract} />
{(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>

View File

@ -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">
<ResolutionOrChance
className="md:hidden"
contract={contract}
large
/>
{(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>
<Col className="hidden items-end justify-between md:flex">
<ResolutionOrChance className="items-end" contract={contract} large />
</Col>
{(isBinary || resolution) && (
<Col className="hidden items-end justify-between md:flex">
<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}`
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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}>
<SEO
title={question}
description={description}
url={`/${props.username}/${props.slug}`}
ogCardProps={ogCardProps}
/>
<Page wide={hasSidePanel}>
{ogCardProps && (
<SEO
title={question}
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}
/>
<BetsSection contract={contract} user={user ?? null} />
>
{contract.outcomeType === 'FREE_RESPONSE' && (
<>
<Spacer h={4} />
<AnswersPanel
contract={contract as any}
answers={props.answers}
/>
<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" />
<MyBetsSummary className="px-2" contract={contract} bets={userBets} />
<Spacer h={6} />
{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,
}
}

View File

@ -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,24 +124,57 @@ 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>
<Spacer h={4} />
<div className="form-control">
<label className="label">
<span className="mb-1">Initial probability</span>
<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>
<ProbabilitySelector
probabilityInt={initialProb}
setProbabilityInt={setInitialProb}
/>
</div>
<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>
</label>
<ProbabilitySelector
probabilityInt={initialProb}
setProbabilityInt={setInitialProb}
/>
</div>
)}
<Spacer h={4} />

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB