Update to 1.3.1, merge branch 'main' into link-summoner

This commit is contained in:
Austin Chen 2022-05-27 12:23:26 -07:00
commit 7caaa69b6f
180 changed files with 5174 additions and 3503 deletions

55
.github/workflows/check.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Check PRs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
FORCE_COLOR: 3
NEXT_TELEMETRY_DISABLED: 1
jobs:
check:
name: Static analysis
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Restore cached node_modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
- name: Install missing dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Run Prettier on web client
working-directory: web
run: npx prettier --check .
- name: Run ESLint on common
if: ${{ success() || failure() }}
working-directory: common
run: npx eslint . --max-warnings 0
- name: Run ESLint on web client
if: ${{ success() || failure() }}
working-directory: web
run: yarn lint --max-warnings 0
- name: Run ESLint on cloud functions
if: ${{ success() || failure() }}
working-directory: functions
run: npx eslint . --max-warnings 0
- name: Run Typescript checker on web client
if: ${{ success() || failure() }}
working-directory: web
run: tsc --pretty --project tsconfig.json --noEmit
- name: Run Typescript checker on cloud functions
if: ${{ success() || failure() }}
working-directory: functions
run: tsc --pretty --project tsconfig.json --noEmit

29
common/.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
env: {
browser: true,
node: true,
},
overrides: [
{
files: ['**/*.ts'],
plugins: ['@typescript-eslint'],
extends: ['plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
],
rules: {
'no-extra-semi': 'off',
'no-unused-vars': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -1,6 +1,14 @@
import { Bet } from './bet'
import { getDpmProbability } from './calculate-dpm'
import { Binary, CPMM, DPM, FreeResponse, FullContract } from './contract'
import { range } from 'lodash'
import { Bet, NumericBet } from './bet'
import { getDpmProbability, getValueFromBucket } from './calculate-dpm'
import {
Binary,
CPMM,
DPM,
FreeResponse,
FullContract,
Numeric,
} from './contract'
import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
@ -80,7 +88,7 @@ export function getAnteBets(
}
export function getFreeAnswerAnte(
creator: User,
anteBettorId: string,
contract: FullContract<DPM, FreeResponse>,
anteBetId: string
) {
@ -92,7 +100,7 @@ export function getFreeAnswerAnte(
const anteBet: Bet = {
id: anteBetId,
userId: creator.id,
userId: anteBettorId,
contractId: contract.id,
amount,
shares,
@ -106,3 +114,42 @@ export function getFreeAnswerAnte(
return anteBet
}
export function getNumericAnte(
creator: User,
contract: FullContract<DPM, Numeric>,
ante: number,
newBetId: string
) {
const { bucketCount, createdTime } = contract
const betAnte = ante / bucketCount
const betShares = Math.sqrt(ante ** 2 / bucketCount)
const allOutcomeShares = Object.fromEntries(
range(0, bucketCount).map((_, i) => [i, betShares])
)
const allBetAmounts = Object.fromEntries(
range(0, bucketCount).map((_, i) => [i, betAnte])
)
const anteBet: NumericBet = {
id: newBetId,
userId: creator.id,
contractId: contract.id,
amount: ante,
allBetAmounts,
outcome: '0',
value: getValueFromBucket('0', contract),
shares: betShares,
allOutcomeShares,
probBefore: 0,
probAfter: 1 / bucketCount,
createdTime,
isAnte: true,
fees: noFees,
}
return anteBet
}

View File

@ -29,4 +29,10 @@ export type Bet = {
createdTime: number
}
export type NumericBet = Bet & {
value: number
allOutcomeShares: { [outcome: string]: number }
allBetAmounts: { [outcome: string]: number }
}
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash'
import { sum, groupBy, mapValues, sumBy } from 'lodash'
import { Binary, CPMM, FullContract } from './contract'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
@ -63,10 +63,8 @@ export function getCpmmLiquidityFee(
bet: number,
outcome: string
) {
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
const probMid = Math.sqrt(probBefore * probAfter)
const betP = outcome === 'YES' ? 1 - probMid : probMid
const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
const betP = outcome === 'YES' ? 1 - prob : prob
const liquidityFee = LIQUIDITY_FEE * betP * bet
const platformFee = PLATFORM_FEE * betP * bet
@ -278,16 +276,16 @@ export function getCpmmLiquidityPoolWeights(
return liquidity
})
const shareSum = _.sum(liquidityShares)
const shareSum = sum(liquidityShares)
const weights = liquidityShares.map((s, i) => ({
weight: s / shareSum,
providerId: liquidities[i].userId,
}))
const userWeights = _.groupBy(weights, (w) => w.providerId)
const totalUserWeights = _.mapValues(userWeights, (userWeight) =>
_.sumBy(userWeight, (w) => w.weight)
const userWeights = groupBy(weights, (w) => w.providerId)
const totalUserWeights = mapValues(userWeights, (userWeight) =>
sumBy(userWeight, (w) => w.weight)
)
return totalUserWeights
}

View File

@ -1,7 +1,16 @@
import * as _ from 'lodash'
import { Bet } from './bet'
import { Binary, DPM, FreeResponse, FullContract } from './contract'
import { cloneDeep, range, sum, sumBy, sortBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet'
import {
Binary,
DPM,
FreeResponse,
FullContract,
Numeric,
NumericContract,
} from './contract'
import { DPM_FEES } from './fees'
import { normpdf } from '../common/util/math'
import { addObjects } from './util/object'
export function getDpmProbability(totalShares: { [outcome: string]: number }) {
// For binary contracts only.
@ -14,11 +23,94 @@ export function getDpmOutcomeProbability(
},
outcome: string
) {
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
const squareSum = sumBy(Object.values(totalShares), (shares) => shares ** 2)
const shares = totalShares[outcome] ?? 0
return shares ** 2 / squareSum
}
export function getDpmOutcomeProbabilities(totalShares: {
[outcome: string]: number
}) {
const squareSum = sumBy(Object.values(totalShares), (shares) => shares ** 2)
return mapValues(totalShares, (shares) => shares ** 2 / squareSum)
}
export function getNumericBets(
contract: NumericContract,
bucket: string,
betAmount: number,
variance: number
) {
const { bucketCount } = contract
const bucketNumber = parseInt(bucket)
const buckets = range(0, bucketCount)
const mean = bucketNumber / bucketCount
const allDensities = buckets.map((i) =>
normpdf(i / bucketCount, mean, variance)
)
const densitySum = sum(allDensities)
const rawBetAmounts = allDensities
.map((d) => (d / densitySum) * betAmount)
.map((x) => (x >= 1 / bucketCount ? x : 0))
const rawSum = sum(rawBetAmounts)
const scaledBetAmounts = rawBetAmounts.map((x) => (x / rawSum) * betAmount)
const bets = scaledBetAmounts
.map((x, i) => (x > 0 ? [i.toString(), x] : undefined))
.filter((x) => x != undefined) as [string, number][]
return bets
}
export const getMappedBucket = (value: number, contract: NumericContract) => {
const { bucketCount, min, max } = contract
const index = Math.floor(((value - min) / (max - min)) * bucketCount)
const bucket = Math.max(Math.min(index, bucketCount - 1), 0)
return `${bucket}`
}
export const getValueFromBucket = (
bucket: string,
contract: NumericContract
) => {
const { bucketCount, min, max } = contract
const index = parseInt(bucket)
const value = min + (index / bucketCount) * (max - min)
const rounded = Math.round(value * 1e4) / 1e4
return rounded
}
export const getExpectedValue = (contract: NumericContract) => {
const { bucketCount, min, max, totalShares } = contract
const totalShareSum = sumBy(
Object.values(totalShares),
(shares) => shares ** 2
)
const probs = range(0, bucketCount).map(
(i) => totalShares[i] ** 2 / totalShareSum
)
const values = range(0, bucketCount).map(
(i) =>
// use mid point within bucket
0.5 * (min + (i / bucketCount) * (max - min)) +
0.5 * (min + ((i + 1) / bucketCount) * (max - min))
)
const weightedValues = range(0, bucketCount).map((i) => probs[i] * values[i])
const expectation = sum(weightedValues)
const rounded = Math.round(expectation * 1e2) / 1e2
return rounded
}
export function getDpmOutcomeProbabilityAfterBet(
totalShares: {
[outcome: string]: number
@ -55,7 +147,7 @@ export function calculateDpmShares(
bet: number,
betChoice: string
) {
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
const squareSum = sumBy(Object.values(totalShares), (shares) => shares ** 2)
const shares = totalShares[betChoice] ?? 0
const c = 2 * bet * Math.sqrt(squareSum)
@ -63,6 +155,30 @@ export function calculateDpmShares(
return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares
}
export function calculateNumericDpmShares(
totalShares: {
[outcome: string]: number
},
bets: [string, number][]
) {
const shares: number[] = []
totalShares = cloneDeep(totalShares)
const order = sortBy(
bets.map(([, amount], i) => [amount, i]),
([amount]) => amount
).map(([, i]) => i)
for (const i of order) {
const [bucket, bet] = bets[i]
shares[i] = calculateDpmShares(totalShares, bet, bucket)
totalShares = addObjects(totalShares, { [bucket]: shares[i] })
}
return { shares, totalShares }
}
export function calculateDpmRawShareValue(
totalShares: {
[outcome: string]: number
@ -71,11 +187,11 @@ export function calculateDpmRawShareValue(
betChoice: string
) {
const currentValue = Math.sqrt(
_.sumBy(Object.values(totalShares), (shares) => shares ** 2)
sumBy(Object.values(totalShares), (shares) => shares ** 2)
)
const postSaleValue = Math.sqrt(
_.sumBy(Object.keys(totalShares), (outcome) =>
sumBy(Object.keys(totalShares), (outcome) =>
outcome === betChoice
? Math.max(0, totalShares[outcome] - shares) ** 2
: totalShares[outcome] ** 2
@ -95,12 +211,12 @@ export function calculateDpmMoneyRatio(
const p = getDpmOutcomeProbability(totalShares, outcome)
const actual = _.sum(Object.values(pool)) - shareValue
const actual = sum(Object.values(pool)) - shareValue
const betAmount = p * amount
const expected =
_.sumBy(
sumBy(
Object.keys(totalBets),
(outcome) =>
getDpmOutcomeProbability(totalShares, outcome) *
@ -152,8 +268,8 @@ export function calculateDpmCancelPayout(
bet: Bet
) {
const { totalBets, pool } = contract
const betTotal = _.sum(Object.values(totalBets))
const poolTotal = _.sum(Object.values(pool))
const betTotal = sum(Object.values(totalBets))
const poolTotal = sum(Object.values(pool))
return (bet.amount / betTotal) * poolTotal
}
@ -163,27 +279,39 @@ export function calculateStandardDpmPayout(
bet: Bet,
outcome: string
) {
const { amount, outcome: betOutcome, shares } = bet
if (betOutcome !== outcome) return 0
const { outcome: betOutcome } = bet
const isNumeric = contract.outcomeType === 'NUMERIC'
if (!isNumeric && betOutcome !== outcome) return 0
const shares = isNumeric
? ((bet as NumericBet).allOutcomeShares ?? {})[outcome]
: bet.shares
if (!shares) return 0
const { totalShares, phantomShares, pool } = contract
if (!totalShares[outcome]) return 0
const poolTotal = _.sum(Object.values(pool))
const poolTotal = sum(Object.values(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 - DPM_FEES) * Math.max(0, winnings - amount)
const amount = isNumeric
? (bet as NumericBet).allBetAmounts[outcome]
: bet.amount
const payout = amount + (1 - DPM_FEES) * Math.max(0, winnings - amount)
return payout
}
export function calculateDpmPayoutAfterCorrectBet(
contract: FullContract<DPM, any>,
bet: Bet
) {
const { totalShares, pool, totalBets } = contract
const { totalShares, pool, totalBets, outcomeType } = contract
const { shares, amount, outcome } = bet
const prevShares = totalShares[outcome] ?? 0
@ -204,45 +332,60 @@ export function calculateDpmPayoutAfterCorrectBet(
...totalBets,
[outcome]: prevTotalBet + amount,
},
outcomeType:
outcomeType === 'NUMERIC'
? 'FREE_RESPONSE' // hack to show payout at particular bet point estimate
: outcomeType,
}
return calculateStandardDpmPayout(newContract, bet, outcome)
}
function calculateMktDpmPayout(contract: FullContract<DPM, any>, bet: Bet) {
function calculateMktDpmPayout(
contract: FullContract<DPM, Binary | FreeResponse | Numeric>,
bet: Bet
) {
if (contract.outcomeType === 'BINARY')
return calculateBinaryMktDpmPayout(contract, bet)
const { totalShares, pool, resolutions } = contract as FullContract<
DPM,
FreeResponse
>
const { totalShares, pool, resolutions, outcomeType } = contract
let probs: { [outcome: string]: number }
if (resolutions) {
const probTotal = _.sum(Object.values(resolutions))
probs = _.mapValues(
const probTotal = sum(Object.values(resolutions))
probs = mapValues(
totalShares,
(_, outcome) => (resolutions[outcome] ?? 0) / probTotal
)
} else {
const squareSum = _.sum(
const squareSum = sum(
Object.values(totalShares).map((shares) => shares ** 2)
)
probs = _.mapValues(totalShares, (shares) => shares ** 2 / squareSum)
probs = mapValues(totalShares, (shares) => shares ** 2 / squareSum)
}
const weightedShareTotal = _.sumBy(Object.keys(totalShares), (outcome) => {
const weightedShareTotal = sumBy(Object.keys(totalShares), (outcome) => {
return probs[outcome] * totalShares[outcome]
})
const { outcome, amount, shares } = bet
const totalPool = _.sum(Object.values(pool))
const poolFrac = (probs[outcome] * shares) / weightedShareTotal
const winnings = poolFrac * totalPool
const poolFrac =
outcomeType === 'NUMERIC'
? sumBy(
Object.keys((bet as NumericBet).allOutcomeShares ?? {}),
(outcome) => {
return (
(probs[outcome] * (bet as NumericBet).allOutcomeShares[outcome]) /
weightedShareTotal
)
}
)
: (probs[outcome] * shares) / weightedShareTotal
const totalPool = sum(Object.values(pool))
const winnings = poolFrac * totalPool
return deductDpmFees(amount, winnings)
}

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash'
import { maxBy } from 'lodash'
import { Bet } from './bet'
import {
calculateCpmmSale,
@ -161,7 +161,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return {
invested: Math.max(0, currentInvested),
currentInvested,
payout,
netPayout,
profit,
@ -181,7 +180,7 @@ export function getContractBetNullMetrics() {
export function getTopAnswer(contract: FreeResponseContract) {
const { answers } = contract
const top = _.maxBy(
const top = maxBy(
answers?.map((answer) => ({
answer,
prob: getOutcomeProbability(contract, answer.id),
@ -190,29 +189,3 @@ export function getTopAnswer(contract: FreeResponseContract) {
)
return top?.answer
}
export function hasUserHitManaLimit(
contract: FreeResponseContract,
bets: Bet[],
amount: number
) {
const { manaLimitPerUser } = contract
if (manaLimitPerUser) {
const contractMetrics = getContractBetMetrics(contract, bets)
const currentInvested = contractMetrics.currentInvested
console.log('user current invested amount', currentInvested)
console.log('mana limit:', manaLimitPerUser)
if (currentInvested + amount > manaLimitPerUser) {
const manaAllowed = manaLimitPerUser - currentInvested
return {
status: 'error',
message: `Market bet cap is M$${manaLimitPerUser}, you've M$${manaAllowed} left`,
}
}
}
return {
status: 'success',
message: '',
}
}

View File

@ -393,6 +393,23 @@ Future plans: We expect to focus on similar theoretical problems in alignment un
description:
'The mission of the Alliance to Feed the Earth in Disasters is to help create resilience to global food shocks. We seek to identify various resilient food solutions and to help governments implement these solutions, to increase the chances that people have enough to eat in the event of a global catastrophe. We focus on events that could deplete food supplies or access to 5% of the global population or more.Our ultimate goal is to feed everyone, no matter what. An important aspect of this goal is that we need to establish equitable solutions so that all people can access the nutrition they need, regardless of wealth or location.ALLFED is inspired by effective altruism, using reason and evidence to identify how to do the most good. Our solutions are backed by science and research, and we also identify the most cost-effective solutions, to be able to provide more nutrition in catastrophes.',
},
{
name: 'The Trevor Project',
website: 'https://www.thetrevorproject.org/',
photo: 'https://i.imgur.com/QN4mVNn.jpeg',
preview: 'The Trevor Project is the worlds largest suicide prevention and crisis intervention organization for LGBTQ (lesbian, gay, bisexual, transgender, queer, and questioning) young people.',
description:
`Two decades ago, we responded to a health crisis. Now were building a safer, more-inclusive world. LGBTQ young people are four times more likely to attempt suicide, and suicide remains the second leading cause of death among all young people in the U.S.
Our Mission
To end suicide among lesbian, gay, bisexual, transgender, queer & questioning young people.
Our Vision
A world where all LGBTQ young people see a bright future for themselves.
Our Goal
To serve 1.8 million crisis contacts annually, by the end of our 25th year, while continuing to innovate on our core services.`,
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -3,7 +3,7 @@ import { Fees } from './fees'
export type FullContract<
M extends DPM | CPMM,
T extends Binary | Multi | FreeResponse
T extends Binary | Multi | FreeResponse | Numeric
> = {
id: string
slug: string // auto-generated; must be unique
@ -11,7 +11,7 @@ export type FullContract<
creatorId: string
creatorName: string
creatorUsername: string
creatorAvatarUrl?: string // Start requiring after 2022-03-01
creatorAvatarUrl?: string
question: string
description: string // More info about what the contract is about
@ -31,8 +31,6 @@ export type FullContract<
closeEmailsSent?: number
manaLimitPerUser?: number
volume: number
volume24Hours: number
volume7Days: number
@ -41,9 +39,13 @@ export type FullContract<
} & M &
T
export type Contract = FullContract<DPM | CPMM, Binary | Multi | FreeResponse>
export type Contract = FullContract<
DPM | CPMM,
Binary | Multi | FreeResponse | Numeric
>
export type BinaryContract = FullContract<DPM | CPMM, Binary>
export type FreeResponseContract = FullContract<DPM | CPMM, FreeResponse>
export type NumericContract = FullContract<DPM, Numeric>
export type DPM = {
mechanism: 'dpm-2'
@ -83,8 +85,22 @@ export type FreeResponse = {
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
}
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE'
export type Numeric = {
outcomeType: 'NUMERIC'
bucketCount: number
min: number
max: number
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
resolutionValue?: number
}
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' | 'NUMERIC'
export const OUTCOME_TYPES = [
'BINARY',
'MULTI',
'FREE_RESPONSE',
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000
export const MAX_TAG_LENGTH = 60

View File

@ -1,16 +1,17 @@
import { escapeRegExp } from 'lodash'
import { DEV_CONFIG } from './dev'
import { EnvConfig, PROD_CONFIG } from './prod'
import { THEOREMONE_CONFIG } from './theoremone'
export const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'DEV'
const CONFIGS = {
const CONFIGS: { [env: string]: EnvConfig } = {
PROD: PROD_CONFIG,
DEV: DEV_CONFIG,
THEOREMONE: THEOREMONE_CONFIG,
}
// @ts-ignore
export const ENV_CONFIG: EnvConfig = CONFIGS[ENV]
export const ENV_CONFIG = CONFIGS[ENV]
export function isWhitelisted(email?: string) {
if (!ENV_CONFIG.whitelistEmail) {
@ -28,3 +29,10 @@ export const DOMAIN = ENV_CONFIG.domain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
// Manifold's domain or any subdomains thereof
export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
)
// Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/

View File

@ -6,6 +6,7 @@ export const DEV_CONFIG: EnvConfig = {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com',
projectId: 'dev-mantic-markets',
region: 'us-central1',
storageBucket: 'dev-mantic-markets.appspot.com',
messagingSenderId: '134303100058',
appId: '1:134303100058:web:27f9ea8b83347251f80323',

View File

@ -18,6 +18,7 @@ type FirebaseConfig = {
apiKey: string
authDomain: string
projectId: string
region: string
storageBucket: string
messagingSenderId: string
appId: string
@ -30,6 +31,7 @@ export const PROD_CONFIG: EnvConfig = {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com',
projectId: 'mantic-markets',
region: 'us-central1',
storageBucket: 'mantic-markets.appspot.com',
messagingSenderId: '128925704902',
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
@ -39,6 +41,7 @@ export const PROD_CONFIG: EnvConfig = {
'akrolsmir@gmail.com', // Austin
'jahooma@gmail.com', // James
'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold
],
visibility: 'PUBLIC',

View File

@ -6,6 +6,7 @@ export const THEOREMONE_CONFIG: EnvConfig = {
apiKey: 'AIzaSyBSXL6Ys7InNHnCKSy-_E_luhh4Fkj4Z6M',
authDomain: 'theoremone-manifold.firebaseapp.com',
projectId: 'theoremone-manifold',
region: 'us-central1',
storageBucket: 'theoremone-manifold.appspot.com',
messagingSenderId: '698012149198',
appId: '1:698012149198:web:b342af75662831aa84b79f',

View File

@ -1,10 +1,12 @@
import * as _ from 'lodash'
import { sumBy } from 'lodash'
import { Bet, MAX_LOAN_PER_CONTRACT } from './bet'
import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import {
calculateDpmShares,
getDpmProbability,
getDpmOutcomeProbability,
getNumericBets,
calculateNumericDpmShares,
} from './calculate-dpm'
import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm'
import {
@ -14,17 +16,27 @@ import {
FreeResponse,
FullContract,
Multi,
NumericContract,
} from './contract'
import { User } from './user'
import { noFees } from './fees'
import { addObjects } from './util/object'
import { NUMERIC_FIXED_VAR } from './numeric-constants'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export type BetInfo = {
newBet: CandidateBet<Bet>
newPool?: { [outcome: string]: number }
newTotalShares?: { [outcome: string]: number }
newTotalBets?: { [outcome: string]: number }
newTotalLiquidity?: number
newP?: number
}
export const getNewBinaryCpmmBetInfo = (
user: User,
outcome: 'YES' | 'NO',
amount: number,
contract: FullContract<CPMM, Binary>,
loanAmount: number,
newBetId: string
loanAmount: number
) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
contract,
@ -32,15 +44,11 @@ export const getNewBinaryCpmmBetInfo = (
outcome
)
const newBalance = user.balance - (amount - loanAmount)
const { pool, p, totalLiquidity } = contract
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(newPool, newP)
const newBet: Bet = {
id: newBetId,
userId: user.id,
const newBet: CandidateBet<Bet> = {
contractId: contract.id,
amount,
shares,
@ -55,16 +63,14 @@ export const getNewBinaryCpmmBetInfo = (
const { liquidityFee } = fees
const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee
return { newBet, newPool, newP, newBalance, newTotalLiquidity, fees }
return { newBet, newPool, newP, newTotalLiquidity }
}
export const getNewBinaryDpmBetInfo = (
user: User,
outcome: 'YES' | 'NO',
amount: number,
contract: FullContract<DPM, Binary>,
loanAmount: number,
newBetId: string
loanAmount: number
) => {
const { YES: yesPool, NO: noPool } = contract.pool
@ -92,9 +98,7 @@ export const getNewBinaryDpmBetInfo = (
const probBefore = getDpmProbability(contract.totalShares)
const probAfter = getDpmProbability(newTotalShares)
const newBet: Bet = {
id: newBetId,
userId: user.id,
const newBet: CandidateBet<Bet> = {
contractId: contract.id,
amount,
loanAmount,
@ -106,18 +110,14 @@ export const getNewBinaryDpmBetInfo = (
fees: noFees,
}
const newBalance = user.balance - (amount - loanAmount)
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
return { newBet, newPool, newTotalShares, newTotalBets }
}
export const getNewMultiBetInfo = (
user: User,
outcome: string,
amount: number,
contract: FullContract<DPM, Multi | FreeResponse>,
loanAmount: number,
newBetId: string
loanAmount: number
) => {
const { pool, totalShares, totalBets } = contract
@ -135,9 +135,7 @@ export const getNewMultiBetInfo = (
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: Bet = {
id: newBetId,
userId: user.id,
const newBet: CandidateBet<Bet> = {
contractId: contract.id,
amount,
loanAmount,
@ -149,14 +147,55 @@ export const getNewMultiBetInfo = (
fees: noFees,
}
const newBalance = user.balance - (amount - loanAmount)
return { newBet, newPool, newTotalShares, newTotalBets }
}
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
export const getNumericBetsInfo = (
value: number,
outcome: string,
amount: number,
contract: NumericContract
) => {
const { pool, totalShares, totalBets } = contract
const bets = getNumericBets(contract, outcome, amount, NUMERIC_FIXED_VAR)
const allBetAmounts = Object.fromEntries(bets)
const newTotalBets = addObjects(totalBets, allBetAmounts)
const newPool = addObjects(pool, allBetAmounts)
const { shares, totalShares: newTotalShares } = calculateNumericDpmShares(
contract.totalShares,
bets
)
const allOutcomeShares = Object.fromEntries(
bets.map(([outcome], i) => [outcome, shares[i]])
)
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: CandidateBet<NumericBet> = {
contractId: contract.id,
value,
amount,
allBetAmounts,
shares: shares.find((s, i) => bets[i][0] === outcome) ?? 0,
allOutcomeShares,
outcome,
probBefore,
probAfter,
createdTime: Date.now(),
fees: noFees,
}
return { newBet, newPool, newTotalShares, newTotalBets }
}
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = _.sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount

View File

@ -1,16 +1,16 @@
import { PHANTOM_ANTE } from './antes'
import { range } from 'lodash'
import {
Binary,
Contract,
CPMM,
DPM,
FreeResponse,
Numeric,
outcomeType,
} from './contract'
import { User } from './user'
import { parseTags } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { calcDpmInitialPool } from './calculate-dpm'
export function getNewContract(
id: string,
@ -23,7 +23,11 @@ export function getNewContract(
ante: number,
closeTime: number,
extraTags: string[],
manaLimitPerUser: number
// used for numeric markets
bucketCount: number,
min: number,
max: number
) {
const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
@ -33,6 +37,8 @@ export function getNewContract(
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max)
: getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({
@ -63,12 +69,14 @@ export function getNewContract(
liquidityFee: 0,
platformFee: 0,
},
manaLimitPerUser,
})
return contract as Contract
}
/*
import { PHANTOM_ANTE } from './antes'
import { calcDpmInitialPool } from './calculate-dpm'
const getBinaryDpmProps = (initialProb: number, ante: number) => {
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
calcDpmInitialPool(initialProb, ante, PHANTOM_ANTE)
@ -85,6 +93,7 @@ const getBinaryDpmProps = (initialProb: number, ante: number) => {
return system
}
*/
const getBinaryCpmmProps = (initialProb: number, ante: number) => {
const pool = { YES: ante, NO: ante }
@ -115,10 +124,33 @@ const getFreeAnswerProps = (ante: number) => {
return system
}
const getMultiProps = (
outcomes: string[],
initialProbs: number[],
ante: number
const getNumericProps = (
ante: number,
bucketCount: number,
min: number,
max: number
) => {
// Not implemented.
const buckets = range(0, bucketCount).map((i) => i.toString())
const betAnte = ante / bucketCount
const pool = Object.fromEntries(buckets.map((answer) => [answer, betAnte]))
const totalBets = pool
const betShares = Math.sqrt(ante ** 2 / bucketCount)
const totalShares = Object.fromEntries(
buckets.map((answer) => [answer, betShares])
)
const system: DPM & Numeric = {
mechanism: 'dpm-2',
outcomeType: 'NUMERIC',
pool,
totalBets,
totalShares,
bucketCount,
min,
max,
}
return system
}

View File

@ -0,0 +1,5 @@
export const NUMERIC_BUCKET_COUNT = 200
export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'

View File

@ -3,6 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {},
"sideEffects": false,
"dependencies": {
"lodash": "4.17.21"
},

View File

@ -1,6 +1,6 @@
import * as _ from 'lodash'
import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet } from './bet'
import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm'
import { DPM, FreeResponse, FullContract, Multi } from './contract'
import {
@ -17,10 +17,10 @@ export const getDpmCancelPayouts = (
bets: Bet[]
) => {
const { pool } = contract
const poolTotal = _.sum(Object.values(pool))
const poolTotal = sum(Object.values(pool))
console.log('resolved N/A, pool M$', poolTotal)
const betSum = _.sumBy(bets, (b) => b.amount)
const betSum = sumBy(bets, (b) => b.amount)
const payouts = bets.map((bet) => ({
userId: bet.userId,
@ -42,8 +42,8 @@ export const getDpmStandardPayouts = (
) => {
const winningBets = bets.filter((bet) => bet.outcome === outcome)
const poolTotal = _.sum(Object.values(contract.pool))
const totalShares = _.sumBy(winningBets, (b) => b.shares)
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) * poolTotal
@ -54,7 +54,7 @@ export const getDpmStandardPayouts = (
return { userId, profit, payout }
})
const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit))
const profits = sumBy(payouts, (po) => Math.max(0, po.profit))
const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits
@ -88,6 +88,64 @@ export const getDpmStandardPayouts = (
}
}
export const getNumericDpmPayouts = (
outcome: string,
contract: FullContract<DPM, any>,
bets: NumericBet[]
) => {
const totalShares = sumBy(bets, (bet) => bet.allOutcomeShares[outcome] ?? 0)
const winningBets = bets.filter((bet) => !!bet.allOutcomeShares[outcome])
const poolTotal = sum(Object.values(contract.pool))
const payouts = winningBets.map(
({ userId, allBetAmounts, allOutcomeShares }) => {
const shares = allOutcomeShares[outcome] ?? 0
const winnings = (shares / totalShares) * poolTotal
const amount = allBetAmounts[outcome] ?? 0
const profit = winnings - amount
// profit can be negative if using phantom shares
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
return { userId, profit, payout }
}
)
const profits = sumBy(payouts, (po) => Math.max(0, po.profit))
const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits
const finalFees: Fees = {
creatorFee,
platformFee,
liquidityFee: 0,
}
const collectedFees = addObjects<Fees>(
finalFees,
contract.collectedFees ?? {}
)
console.log(
'resolved numeric bucket: ',
outcome,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
liquidityPayouts: [],
collectedFees,
}
}
export const getDpmMktPayouts = (
contract: FullContract<DPM, any>,
bets: Bet[],
@ -98,7 +156,7 @@ export const getDpmMktPayouts = (
? getDpmProbability(contract.totalShares)
: resolutionProbability
const weightedShareTotal = _.sumBy(bets, (b) =>
const weightedShareTotal = sumBy(bets, (b) =>
b.outcome === 'YES' ? p * b.shares : (1 - p) * b.shares
)
@ -112,7 +170,7 @@ export const getDpmMktPayouts = (
return { userId, profit, payout }
})
const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit))
const profits = sumBy(payouts, (po) => Math.max(0, po.profit))
const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits
@ -152,15 +210,15 @@ export const getPayoutsMultiOutcome = (
contract: FullContract<DPM, Multi | FreeResponse>,
bets: Bet[]
) => {
const poolTotal = _.sum(Object.values(contract.pool))
const poolTotal = sum(Object.values(contract.pool))
const winningBets = bets.filter((bet) => resolutions[bet.outcome])
const betsByOutcome = _.groupBy(winningBets, (bet) => bet.outcome)
const sharesByOutcome = _.mapValues(betsByOutcome, (bets) =>
_.sumBy(bets, (bet) => bet.shares)
const betsByOutcome = groupBy(winningBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (bet) => bet.shares)
)
const probTotal = _.sum(Object.values(resolutions))
const probTotal = sum(Object.values(resolutions))
const payouts = winningBets.map(({ userId, outcome, amount, shares }) => {
const prob = resolutions[outcome] / probTotal
@ -171,7 +229,7 @@ export const getPayoutsMultiOutcome = (
return { userId, profit, payout }
})
const profits = _.sumBy(payouts, (po) => po.profit)
const profits = sumBy(payouts, (po) => po.profit)
const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash'
import { sum } from 'lodash'
import { Bet } from './bet'
import { getProbability } from './calculate'
@ -50,7 +50,7 @@ export const getStandardFixedPayouts = (
'pool',
contract.pool[outcome],
'payouts',
_.sum(payouts),
sum(payouts),
'creator fee',
creatorPayout
)
@ -105,7 +105,7 @@ export const getMktFixedPayouts = (
'pool',
p * contract.pool.YES + (1 - p) * contract.pool.NO,
'payouts',
_.sum(payouts),
sum(payouts),
'creator fee',
creatorPayout
)

View File

@ -1,6 +1,6 @@
import * as _ from 'lodash'
import { sumBy, groupBy, mapValues } from 'lodash'
import { Bet } from './bet'
import { Bet, NumericBet } from './bet'
import {
Binary,
Contract,
@ -16,6 +16,7 @@ import {
getDpmCancelPayouts,
getDpmMktPayouts,
getDpmStandardPayouts,
getNumericDpmPayouts,
getPayoutsMultiOutcome,
} from './payouts-dpm'
import {
@ -31,16 +32,19 @@ export type Payout = {
export const getLoanPayouts = (bets: Bet[]): Payout[] => {
const betsWithLoans = bets.filter((bet) => bet.loanAmount)
const betsByUser = _.groupBy(betsWithLoans, (bet) => bet.userId)
const loansByUser = _.mapValues(betsByUser, (bets) =>
_.sumBy(bets, (bet) => -(bet.loanAmount ?? 0))
const betsByUser = groupBy(betsWithLoans, (bet) => bet.userId)
const loansByUser = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => -(bet.loanAmount ?? 0))
)
return _.toPairs(loansByUser).map(([userId, payout]) => ({ userId, payout }))
return Object.entries(loansByUser).map(([userId, payout]) => ({
userId,
payout,
}))
}
export const groupPayoutsByUser = (payouts: Payout[]) => {
const groups = _.groupBy(payouts, (payout) => payout.userId)
return _.mapValues(groups, (group) => _.sumBy(group, (g) => g.payout))
const groups = groupBy(payouts, (payout) => payout.userId)
return mapValues(groups, (group) => sumBy(group, (g) => g.payout))
}
export type PayoutInfo = {
@ -131,6 +135,9 @@ export const getDpmPayouts = (
return getDpmCancelPayouts(contract, openBets)
default:
if (contract.outcomeType === 'NUMERIC')
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
// Outcome is a free response answer id.
return getDpmStandardPayouts(outcome, contract, openBets)
}

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash'
import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash'
import { Bet } from './bet'
import { Contract } from './contract'
import { ClickEvent } from './tracking'
@ -21,13 +21,13 @@ export const getRecommendedContracts = (
const yourWordFrequency = contractsToWordFrequency(yourContracts)
const otherWordFrequency = contractsToWordFrequency(notYourContracts)
const words = _.union(
const words = union(
Object.keys(yourWordFrequency),
Object.keys(otherWordFrequency)
)
const yourWeightedFrequency = _.fromPairs(
_.map(words, (word) => {
const yourWeightedFrequency = Object.fromEntries(
words.map((word) => {
const [yourFreq, otherFreq] = [
yourWordFrequency[word] ?? 0,
otherWordFrequency[word] ?? 0,
@ -47,7 +47,7 @@ export const getRecommendedContracts = (
const scoredContracts = contracts.map((contract) => {
const wordFrequency = contractToWordFrequency(contract)
const score = _.sumBy(Object.keys(wordFrequency), (word) => {
const score = sumBy(Object.keys(wordFrequency), (word) => {
const wordFreq = wordFrequency[word] ?? 0
const weight = yourWeightedFrequency[word] ?? 0
return wordFreq * weight
@ -59,7 +59,7 @@ export const getRecommendedContracts = (
}
})
return _.sortBy(scoredContracts, (scored) => -scored.score).map(
return sortBy(scoredContracts, (scored) => -scored.score).map(
(scored) => scored.contract
)
}
@ -87,8 +87,8 @@ const getWordsCount = (text: string) => {
}
const toFrequency = (counts: { [word: string]: number }) => {
const total = _.sum(Object.values(counts))
return _.mapValues(counts, (count) => count / total)
const total = sum(Object.values(counts))
return mapValues(counts, (count) => count / total)
}
const contractToWordFrequency = (contract: Contract) =>
@ -108,8 +108,8 @@ export const getWordScores = (
clicks: ClickEvent[],
bets: Bet[]
) => {
const contractClicks = _.groupBy(clicks, (click) => click.contractId)
const contractBets = _.groupBy(bets, (bet) => bet.contractId)
const contractClicks = groupBy(clicks, (click) => click.contractId)
const contractBets = groupBy(bets, (bet) => bet.contractId)
const yourContracts = contracts.filter(
(c) =>
@ -117,25 +117,22 @@ export const getWordScores = (
)
const yourTfIdf = calculateContractTfIdf(yourContracts)
const contractWordScores = _.mapValues(
yourTfIdf,
(wordsTfIdf, contractId) => {
const viewCount = contractViewCounts[contractId] ?? 0
const clickCount = contractClicks[contractId]?.length ?? 0
const betCount = contractBets[contractId]?.length ?? 0
const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => {
const viewCount = contractViewCounts[contractId] ?? 0
const clickCount = contractClicks[contractId]?.length ?? 0
const betCount = contractBets[contractId]?.length ?? 0
const factor =
-1 * Math.log(viewCount + 1) +
10 * Math.log(betCount + clickCount / 4 + 1)
const factor =
-1 * Math.log(viewCount + 1) +
10 * Math.log(betCount + clickCount / 4 + 1)
return _.mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
}
)
return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
})
const wordScores = Object.values(contractWordScores).reduce(addObjects, {})
const minScore = Math.min(...Object.values(wordScores))
const maxScore = Math.max(...Object.values(wordScores))
const normalizedWordScores = _.mapValues(
const normalizedWordScores = mapValues(
wordScores,
(score) => (score - minScore) / (maxScore - minScore)
)
@ -156,7 +153,7 @@ export function getContractScore(
if (Object.keys(wordScores).length === 0) return 1
const wordFrequency = contractToWordFrequency(contract)
const score = _.sumBy(Object.keys(wordFrequency), (word) => {
const score = sumBy(Object.keys(wordFrequency), (word) => {
const wordFreq = wordFrequency[word] ?? 0
const weight = wordScores[word] ?? 0
return wordFreq * weight
@ -178,11 +175,13 @@ function calculateContractTfIdf(contracts: Contract[]) {
}
}
const wordIdf = _.mapValues(wordsCount, (count) =>
const wordIdf = mapValues(wordsCount, (count) =>
Math.log(contracts.length / count)
)
const contractWordsTfIdf = _.map(contractFreq, (wordFreq) =>
_.mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
const contractWordsTfIdf = contractFreq.map((wordFreq) =>
mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
)
return Object.fromEntries(
contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])
)
return _.fromPairs(contracts.map((c, i) => [c.id, contractWordsTfIdf[i]]))
}

View File

@ -1,13 +1,13 @@
import * as _ from 'lodash'
import { groupBy, sumBy, mapValues, partition } from 'lodash'
import { Bet } from './bet'
import { Binary, Contract, FullContract } from './contract'
import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[], bets: Bet[][]) {
const creatorScore = _.mapValues(
_.groupBy(contracts, ({ creatorId }) => creatorId),
(contracts) => _.sumBy(contracts, ({ pool }) => pool.YES + pool.NO)
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
groupBy(contracts, ({ creatorId }) => creatorId),
(contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO)
)
return creatorScore
@ -30,7 +30,7 @@ export function scoreUsersByContract(
) {
const { resolution, resolutionProbability } = contract
const [closedBets, openBets] = _.partition(
const [closedBets, openBets] = partition(
bets,
(bet) => bet.isSold || bet.sale
)
@ -58,9 +58,9 @@ export function scoreUsersByContract(
const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]
const userScore = _.mapValues(
_.groupBy(netPayouts, (payout) => payout.userId),
(payouts) => _.sumBy(payouts, ({ payout }) => payout)
const userScore = mapValues(
groupBy(netPayouts, (payout) => payout.userId),
(payouts) => sumBy(payouts, ({ payout }) => payout)
)
return userScore

12
common/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"baseUrl": "../",
"moduleResolution": "node",
"noImplicitReturns": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"include": ["**/*.ts"]
}

View File

@ -29,6 +29,20 @@ export function formatPercent(zeroToOne: number) {
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
}
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
export function formatLargeNumber(num: number, sigfigs = 2): string {
const absNum = Math.abs(num)
if (absNum < 1000) {
return '' + Number(num.toPrecision(sigfigs))
}
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const suffixIdx = Math.floor(Math.log10(absNum) / 3)
const suffixStr = suffix[suffixIdx]
const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs)
return `${Number(numStr)}${suffixStr}`
}
export function toCamelCase(words: string) {
const camelCase = words
.split(' ')

View File

@ -4,3 +4,16 @@ export const logInterpolation = (min: number, max: number, value: number) => {
return Math.log(value - min + 1) / Math.log(max - min + 1)
}
export function normpdf(x: number, mean = 0, variance = 1) {
if (variance === 0) {
return x === mean ? Infinity : 0
}
return (
Math.exp((-0.5 * Math.pow(x - mean, 2)) / variance) /
Math.sqrt(TAU * variance)
)
}
const TAU = Math.PI * 2

View File

@ -1,9 +1,9 @@
import * as _ from 'lodash'
import { union } from 'lodash'
export const removeUndefinedProps = <T>(obj: T): T => {
let newObj: any = {}
const newObj: any = {}
for (let key of Object.keys(obj)) {
for (const key of Object.keys(obj)) {
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
}
@ -14,10 +14,10 @@ export const addObjects = <T extends { [key: string]: number }>(
obj1: T,
obj2: T
) => {
const keys = _.union(Object.keys(obj1), Object.keys(obj2))
const keys = union(Object.keys(obj1), Object.keys(obj2))
const newObj = {} as any
for (let key of keys) {
for (const key of keys) {
newObj[key] = (obj1[key] ?? 0) + (obj2[key] ?? 0)
}

View File

@ -5,7 +5,8 @@ export const randomString = (length = 12) =>
export function genHash(str: string) {
// xmur3
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
let h: number
for (let i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
@ -28,7 +29,7 @@ export function createRNG(seed: string) {
b >>>= 0
c >>>= 0
d >>>= 0
var t = (a + b) | 0
let t = (a + b) | 0
a = b ^ (b >>> 9)
b = (c + (c << 3)) | 0
c = (c << 21) | (c >>> 11)
@ -39,7 +40,7 @@ export function createRNG(seed: string) {
}
}
export const shuffle = (array: any[], rand: () => number) => {
export const shuffle = (array: unknown[], rand: () => number) => {
for (let i = 0; i < array.length; i++) {
const swapIndex = Math.floor(rand() * (array.length - i))
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]

25
functions/.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
env: {
node: true,
},
overrides: [
{
files: ['**/*.ts'],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
},
],
rules: {
'no-extra-semi': 'off',
'no-unused-vars': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -24,8 +24,9 @@ Adapted from https://firebase.google.com/docs/functions/get-started
0. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev
1. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
2. `$ brew install java` to install java if you don't already have it
1. `$ echo 'export PATH="/usr/local/opt/openjdk/bin:$PATH"' >> ~/.zshrc` to add java to your path
2. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
1. `$ brew install java`
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
3. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
4. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
5. `$ mkdir firestore_export` to create a folder to store the exported database

View File

@ -26,15 +26,15 @@
"firebase-functions": "3.16.0",
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"react-query": "3.39.0",
"module-alias": "2.2.2",
"stripe": "8.194.0"
"react-query": "3.39.0",
"stripe": "8.194.0",
"zod": "3.17.2"
},
"devDependencies": {
"@types/module-alias": "2.0.1",
"@types/mailgun-js": "0.22.12",
"firebase-functions-test": "0.3.3",
"typescript": "4.5.3"
"@types/module-alias": "2.0.1",
"firebase-functions-test": "0.3.3"
},
"private": true
}

View File

@ -1,13 +1,19 @@
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import * as Cors from 'cors'
import { z } from 'zod'
import { User, PrivateUser } from 'common/user'
import { User, PrivateUser } from '../../common/user'
import {
CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST,
} from '../../common/envs/constants'
type Output = Record<string, unknown>
type Request = functions.https.Request
type Response = functions.Response
type Handler = (req: Request, res: Response) => Promise<any>
type AuthedUser = [User, PrivateUser]
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials
@ -15,10 +21,13 @@ type Credentials = JwtCredentials | KeyCredentials
export class APIError {
code: number
msg: string
constructor(code: number, msg: string) {
details: unknown
constructor(code: number, msg: string, details?: unknown) {
this.code = code
this.msg = msg
this.details = details
}
toJson() {}
}
export const parseCredentials = async (req: Request): Promise<Credentials> => {
@ -36,14 +45,11 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
case 'Bearer':
try {
const jwt = await admin.auth().verifyIdToken(payload)
if (!jwt.user_id) {
throw new APIError(403, 'JWT must contain Manifold user ID.')
}
return { kind: 'jwt', data: jwt }
} catch (err) {
// This is somewhat suspicious, so get it into the firebase console
functions.logger.error('Error verifying Firebase JWT: ', err)
throw new APIError(403, `Error validating token: ${err}.`)
throw new APIError(403, 'Error validating token.')
}
case 'Key':
return { kind: 'key', data: payload }
@ -59,6 +65,9 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
switch (creds.kind) {
case 'jwt': {
const { user_id } = creds.data
if (typeof user_id !== 'string') {
throw new APIError(403, 'JWT must contain Manifold user ID.')
}
const [userSnap, privateUserSnap] = await Promise.all([
users.doc(user_id).get(),
privateUsers.doc(user_id).get(),
@ -90,10 +99,11 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
}
}
export const CORS_ORIGIN_MANIFOLD = /^https?:\/\/.+\.manifold\.markets$/
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
export const applyCors = (req: any, res: any, params: object) => {
export const applyCors = (
req: Request,
res: Response,
params: Cors.CorsOptions
) => {
return new Promise((resolve, reject) => {
Cors(params)(req, res, (result) => {
if (result instanceof Error) {
@ -104,10 +114,31 @@ export const applyCors = (req: any, res: any, params: object) => {
})
}
export const zTimestamp = () => {
return z.preprocess((arg) => {
return typeof arg == 'number' ? new Date(arg) : undefined
}, z.date())
}
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val)
if (!result.success) {
const issues = result.error.issues.map((i) => {
return {
field: i.path.join('.') || null,
error: i.message,
}
})
throw new APIError(400, 'Error validating request.', issues)
} else {
return result.data as z.infer<T>
}
}
export const newEndpoint = (methods: [string], fn: Handler) =>
functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => {
await applyCors(req, res, {
origins: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
methods: methods,
})
try {
@ -115,15 +146,18 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
const allowed = methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`)
}
const data = await fn(req, res)
data.status = 'success'
res.status(200).json({ data: data })
const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser))
} catch (e) {
if (e instanceof APIError) {
// Emit a 200 anyway here for now, for backwards compatibility
res.status(200).json({ data: { status: 'error', message: e.msg } })
const output: { [k: string]: unknown } = { message: e.msg }
if (e.details != null) {
output.details = e.details
}
res.status(e.code).json(output)
} else {
res.status(500).json({ data: { status: 'error', message: '???' } })
functions.logger.error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
}
}
})

View File

@ -12,8 +12,6 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { Bet } from '../../common/bet'
import { hasUserHitManaLimit } from '../../common/calculate'
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
async (
@ -62,18 +60,6 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const { status, message } = hasUserHitManaLimit(
contract,
yourBets,
amount
)
if (status === 'error') return { status, message: message }
const [lastAnswer] = await getValues<Answer>(
firestore
.collection(`contracts/${contractId}/answers`)
@ -107,23 +93,20 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
}
transaction.create(newAnswerDoc, answer)
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
const loanAmount = 0
const loanAmount = 0 // getLoanAmount(yourBets, amount)
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
const { newBet, newPool, newTotalShares, newTotalBets } =
getNewMultiBetInfo(
user,
answerId,
amount,
contract as FullContract<DPM, FreeResponse>,
loanAmount,
newBetDoc.id
loanAmount
)
transaction.create(newBetDoc, newBet)
const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
transaction.update(userDoc, { balance: newBalance })
transaction.update(contractDoc, {
pool: newPool,
totalShares: newTotalShares,
@ -132,13 +115,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
volume: volume + amount,
})
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
return { status: 'success', answerId, betId: newBetDoc.id, answer }
return { status: 'success', answerId, betId: betDoc.id, answer }
})
const { answer } = result

View File

@ -1,7 +1,6 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { chargeUser } from './utils'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import {
Binary,
Contract,
@ -12,82 +11,82 @@ import {
MAX_DESCRIPTION_LENGTH,
MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH,
Numeric,
OUTCOME_TYPES,
} from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { getNewContract } from '../../common/new-contract'
import { chargeUser } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api'
import {
FIXED_ANTE,
getAnteBets,
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
MINIMUM_ANTE,
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
export const createContract = newEndpoint(['POST'], async (req, _res) => {
const [creator, _privateUser] = await lookupUser(await parseCredentials(req))
let {
question,
outcomeType,
description,
initialProb,
closeTime,
tags,
manaLimitPerUser,
} = req.body.data || {}
const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
description: z.string().max(MAX_DESCRIPTION_LENGTH),
tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
closeTime: zTimestamp().refine(
(date) => date.getTime() > new Date().getTime(),
'Close time must be in the future.'
),
outcomeType: z.enum(OUTCOME_TYPES),
})
if (!question || typeof question != 'string')
throw new APIError(400, 'Missing or invalid question field')
const binarySchema = z.object({
initialProb: z.number().min(1).max(99),
})
question = question.slice(0, MAX_QUESTION_LENGTH)
const numericSchema = z.object({
min: z.number(),
max: z.number(),
})
if (typeof description !== 'string')
throw new APIError(400, 'Invalid description field')
description = description.slice(0, MAX_DESCRIPTION_LENGTH)
if (tags !== undefined && !Array.isArray(tags))
throw new APIError(400, 'Invalid tags field')
tags = (tags || []).map((tag: string) =>
tag.toString().slice(0, MAX_TAG_LENGTH)
export const createContract = newEndpoint(['POST'], async (req, [user, _]) => {
const { question, description, tags, closeTime, outcomeType } = validate(
bodySchema,
req.body
)
outcomeType = outcomeType ?? 'BINARY'
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
throw new APIError(400, 'Invalid outcomeType')
let min, max, initialProb
if (outcomeType === 'NUMERIC') {
;({ min, max } = validate(numericSchema, req.body))
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.')
}
if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body))
}
if (
outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99)
)
throw new APIError(400, 'Invalid initial probability')
// Uses utc time on server:
const today = new Date()
let freeMarketResetTime = today.setUTCHours(16, 0, 0, 0)
if (today.getTime() < freeMarketResetTime) {
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
}
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', creator.id)
.where('createdTime', '>=', today)
.where('creatorId', '==', user.id)
.where('createdTime', '>=', freeMarketResetTime)
.get()
console.log('free market reset time: ', freeMarketResetTime)
const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE
if (
ante === undefined ||
ante < MINIMUM_ANTE ||
(ante > creator.balance && !isFree) ||
isNaN(ante) ||
!isFinite(ante)
)
throw new APIError(400, 'Invalid ante')
console.log(
'creating contract for',
creator.username,
user.username,
'on',
question,
'ante:',
@ -95,83 +94,92 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => {
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
user,
question,
outcomeType,
description,
initialProb,
initialProb ?? 0,
ante,
closeTime,
closeTime.getTime(),
tags ?? [],
manaLimitPerUser ?? 0
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0
)
if (!isFree && ante) await chargeUser(creator.id, ante, true)
if (!isFree && ante) await chargeUser(user.id, ante, true)
await contractRef.create(contract)
if (ante) {
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets(
creator,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
const noBetDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const { yesBet, noBet } = getAnteBets(
user,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const lp = getCpmmInitialLiquidity(
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante
)
const lp = getCpmmInitialLiquidity(
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante
)
await liquidityDoc.set(lp)
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
await liquidityDoc.set(lp)
} 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 noneAnswer = getNoneAnswer(contract.id, user)
await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(
creator,
contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
const anteBet = getFreeAnswerAnte(
providerId,
contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
} else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getNumericAnte(
user,
contract as FullContract<DPM, Numeric>,
ante,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
return { contract: contract }
return contract
})
const getSlug = async (question: string) => {

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getUser } from './utils'
import { Contract } from '../../common/contract'
@ -34,7 +33,7 @@ export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
return { status: 'error', message: 'About must be a string' }
about = about.trim().slice(0, 140)
if (!_.isArray(tags))
if (!Array.isArray(tags))
return { status: 'error', message: 'Tags must be an array of strings' }
console.log(

View File

@ -1,5 +1,3 @@
import * as _ from 'lodash'
import { DOMAIN, PROJECT_ID } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
@ -9,6 +7,8 @@ import { Contract, FreeResponseContract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format'
import { getValueFromBucket } from '../../common/calculate-dpm'
import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
@ -104,6 +104,12 @@ const toDisplayResolution = (
if (resolution === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A'
if (contract.outcomeType === 'NUMERIC' && contract.mechanism === 'dpm-2')
return (
contract.resolutionValue?.toString() ??
getValueFromBucket(resolution, contract).toString()
)
const answer = (contract as FreeResponseContract).answers?.find(
(a) => a.id === resolution
)
@ -244,7 +250,8 @@ export const sendNewCommentEmail = async (
contract: Contract,
comment: Comment,
bet?: Bet,
answer?: Answer
answerText?: string,
answerId?: string
) => {
const privateUser = await getPrivateUser(userId)
if (
@ -255,7 +262,7 @@ export const sendNewCommentEmail = async (
return
const { question, creatorUsername, slug } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment`
@ -273,9 +280,8 @@ export const sendNewCommentEmail = async (
const subject = `Comment on ${question}`
const from = `${commentorName} <info@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = answer?.text ?? ''
const answerNumber = `#${answer?.id ?? ''}`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}`
await sendTemplateEmail(
privateUser.email,

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getContract } from './utils'
import { Bet } from '../../common/bet'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment'
@ -34,7 +34,14 @@ export const onCreateComment = functions.firestore
let bet: Bet | undefined
let answer: Answer | undefined
if (comment.betId) {
if (comment.answerOutcome) {
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers?.find(
(answer) => answer.id === comment.answerOutcome
)
: undefined
} else if (comment.betId) {
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
@ -53,7 +60,7 @@ export const onCreateComment = functions.firestore
firestore.collection('contracts').doc(contractId).collection('comments')
)
const recipientUserIds = _.uniq([
const recipientUserIds = uniq([
contract.creatorId,
...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId)
@ -66,7 +73,8 @@ export const onCreateComment = functions.firestore
contract,
comment,
bet,
answer
answer?.text,
answer?.id
)
)
)

View File

@ -1,141 +1,112 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import { APIError, newEndpoint, validate } from './api'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import {
BetInfo,
getNewBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo,
getNewMultiBetInfo,
getNumericBetsInfo,
} from '../../common/new-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { Bet } from '../../common/bet'
import { redeemShares } from './redeem-shares'
import { Fees } from '../../common/fees'
import { hasUserHitManaLimit } from '../../common/calculate'
export const placeBet = newEndpoint(['POST'], async (req, _res) => {
const [bettor, _privateUser] = await lookupUser(await parseCredentials(req))
const { amount, outcome, contractId } = req.body.data || {}
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gte(1),
})
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
throw new APIError(400, 'Invalid amount')
const binarySchema = z.object({
outcome: z.enum(['YES', 'NO']),
})
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
throw new APIError(400, 'Invalid outcome')
const freeResponseSchema = z.object({
outcome: z.string(),
})
// run as transaction to prevent race conditions
return await firestore
.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const numericSchema = z.object({
outcome: z.string(),
value: z.number(),
})
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
export const placeBet = newEndpoint(['POST'], async (req, [bettor, _]) => {
const { amount, contractId } = validate(bodySchema, req.body)
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed')
const result = await firestore.runTransaction(async (trans) => {
const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await trans.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', bettor.id)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as Contract
const loanAmount = 0 // getLoanAmount(yourBets, amount)
if (user.balance < amount) throw new APIError(400, 'Insufficient balance')
const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.')
if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome)
)
if (!answerSnap.exists) throw new APIError(400, 'Invalid contract')
const { status, message } = hasUserHitManaLimit(
contract,
yourBets,
amount
)
if (status === 'error') throw new APIError(400, message)
const {
newBet,
newPool,
newTotalShares,
newTotalBets,
newTotalLiquidity,
newP,
} = await (async (): Promise<BetInfo> => {
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
const { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc)
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract)
} else {
throw new APIError(500, 'Contract has invalid type/mechanism.')
}
})()
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
if (newP != null && !isFinite(newP)) {
throw new APIError(400, 'Trade rejected due to overflow error.')
}
const {
newBet,
newPool,
newTotalShares,
newTotalBets,
newBalance,
newTotalLiquidity,
fees,
newP,
} =
outcomeType === 'BINARY'
? mechanism === 'dpm-2'
? getNewBinaryDpmBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
loanAmount,
newBetDoc.id
)
: (getNewBinaryCpmmBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
loanAmount,
newBetDoc.id
) as any)
: getNewMultiBetInfo(
user,
outcome,
amount,
contract as any,
loanAmount,
newBetDoc.id
)
const newBalance = user.balance - amount - loanAmount
const betDoc = contractDoc.collection('bets').doc()
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
trans.update(userDoc, { balance: newBalance })
trans.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects(newBet.fees, collectedFees),
volume: volume + amount,
})
)
if (newP !== undefined && !isFinite(newP)) {
throw new APIError(400, 'Trade rejected due to overflow error.')
}
return { betId: betDoc.id }
})
transaction.create(newBetDoc, newBet)
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
volume: volume + Math.abs(amount),
})
)
if (!isFinite(newBalance)) {
throw new APIError(500, 'Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
return { betId: newBetDoc.id }
})
.then(async (result) => {
await redeemShares(bettor.id, contractId)
return result
})
await redeemShares(bettor.id, contractId)
return result
})
const firestore = admin.firestore()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { partition, sumBy } from 'lodash'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
@ -25,14 +25,14 @@ export const redeemShares = async (userId: string, contractId: string) => {
.where('userId', '==', userId)
)
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const [yesBets, noBets] = _.partition(bets, (b) => b.outcome === 'YES')
const yesShares = _.sumBy(yesBets, (b) => b.shares)
const noShares = _.sumBy(noBets, (b) => b.shares)
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const amount = Math.min(yesShares, noShares)
if (amount <= 0) return
const prevLoanAmount = _.sumBy(bets, (bet) => bet.loanAmount ?? 0)
const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPaid = Math.min(prevLoanAmount, amount)
const netAmount = amount - loanPaid

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
@ -22,6 +22,7 @@ export const resolveMarket = functions
async (
data: {
outcome: string
value?: number
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }
@ -31,7 +32,7 @@ export const resolveMarket = functions
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { outcome, contractId, probabilityInt, resolutions } = data
const { outcome, contractId, probabilityInt, resolutions, value } = data
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
@ -50,10 +51,16 @@ export const resolveMarket = functions
outcome !== 'CANCEL'
)
return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'NUMERIC') {
if (isNaN(+outcome) && outcome !== 'CANCEL')
return { status: 'error', message: 'Invalid outcome' }
} else {
return { status: 'error', message: 'Invalid contract outcomeType' }
}
if (value !== undefined && !isFinite(value))
return { status: 'error', message: 'Invalid value' }
if (
outcomeType === 'BINARY' &&
probabilityInt !== undefined &&
@ -108,6 +115,7 @@ export const resolveMarket = functions
removeUndefinedProps({
isResolved: true,
resolution: outcome,
resolutionValue: value,
resolutionTime,
closeTime: newCloseTime,
resolutionProbability,
@ -179,13 +187,13 @@ const sendResolutionEmails = async (
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const nonWinners = _.difference(
_.uniq(openBets.map(({ userId }) => userId)),
const nonWinners = difference(
uniq(openBets.map(({ userId }) => userId)),
Object.keys(userPayouts)
)
const investedByUser = _.mapValues(
_.groupBy(openBets, (bet) => bet.userId),
(bets) => _.sumBy(bets, (bet) => bet.amount)
const investedByUser = mapValues(
groupBy(openBets, (bet) => bet.userId),
(bets) => sumBy(bets, (bet) => bet.amount)
)
const emailPayouts = [
...Object.entries(userPayouts),

View File

@ -0,0 +1,25 @@
// We have many old contracts without a collectedFees data structure. Let's fill them in.
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { noFees } from '../../../common/fees'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const contractsRef = firestore.collection('contracts')
contractsRef.get().then((contractsSnaps) => {
let n = 0
console.log(`Loaded ${contractsSnaps.size} contracts.`)
contractsSnaps.forEach((ct) => {
const data = ct.data()
if (!('collectedFees' in data)) {
n += 1
console.log(`Filling in missing fees on contract ${data.id}...`)
ct.ref.update({ collectedFees: noFees })
}
})
console.log(`Updated ${n} contracts.`)
})
}

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sortBy } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -20,7 +20,7 @@ async function migrateContract(
.get()
.then((snap) => snap.docs.map((bet) => bet.data() as Bet))
const lastBet = _.sortBy(bets, (bet) => -bet.createdTime)[0]
const lastBet = sortBy(bets, (bet) => -bet.createdTime)[0]
if (lastBet) {
const probAfter = getDpmProbability(contract.totalShares)

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import * as fs from 'fs'
import { initAdmin } from './script-init'

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { uniq } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -19,7 +19,7 @@ async function lowercaseFoldTags() {
const foldRef = firestore.doc(`folds/${fold.id}`)
const { tags } = fold
const lowercaseTags = _.uniq(tags.map((tag) => tag.toLowerCase()))
const lowercaseTags = uniq(tags.map((tag) => tag.toLowerCase()))
console.log('Adding lowercase tags', fold.slug, lowercaseTags)

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sumBy } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -25,8 +25,8 @@ async function migrateContract(contractRef: DocRef, contract: Contract) {
.then((snap) => snap.docs.map((bet) => bet.data() as Bet))
const totalShares = {
YES: _.sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: _.sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
YES: sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
}
await contractRef.update({ totalShares })

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sortBy } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -48,7 +48,7 @@ async function recalculateContract(contractRef: DocRef, isCommit = false) {
const betsRef = contractRef.collection('bets')
const betDocs = await transaction.get(betsRef)
const bets = _.sortBy(
const bets = sortBy(
betDocs.docs.map((d) => d.data() as Bet),
(b) => b.createdTime
)

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sortBy, sumBy } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -35,7 +35,7 @@ async function recalculateContract(
const contract = contractDoc.data() as FullContract<DPM, Binary>
const betDocs = await transaction.get(contractRef.collection('bets'))
const bets = _.sortBy(
const bets = sortBy(
betDocs.docs.map((d) => d.data() as Bet),
(b) => b.createdTime
)
@ -43,8 +43,8 @@ async function recalculateContract(
const phantomAnte = startPool.YES + startPool.NO
const leftovers =
_.sumBy(bets, (b) => b.amount) -
_.sumBy(bets, (b) => {
sumBy(bets, (b) => b.amount) -
sumBy(bets, (b) => {
if (!b.sale) return b.amount
const soldBet = bets.find((bet) => bet.id === b.sale?.betId)
return soldBet?.amount || 0

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { flatten, groupBy, sumBy, mapValues } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -35,12 +35,12 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
)
const loanPayouts = getLoanPayouts(openBets)
const groups = _.groupBy(
const groups = groupBy(
[...payouts, ...loanPayouts],
(payout) => payout.userId
)
const userPayouts = _.mapValues(groups, (group) =>
_.sumBy(group, (g) => g.payout)
const userPayouts = mapValues(groups, (group) =>
sumBy(group, (g) => g.payout)
)
const entries = Object.entries(userPayouts)
@ -93,7 +93,7 @@ async function payOutContractAgain() {
)
)
const flattened = _.flatten(toPayOutAgain.map((d) => d.toBePaidOut))
const flattened = flatten(toPayOutAgain.map((d) => d.toBePaidOut))
for (const [userId, payout] of flattened) {
console.log('Paying out', userId, payout)

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sumBy } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -20,13 +20,13 @@ async function recalculateContract(contractRef: DocRef, contract: Contract) {
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const totalShares = {
YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
YES: sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
}
const totalBets = {
YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)),
NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)),
YES: sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)),
NO: sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)),
}
await contractRef.update({ totalShares, totalBets })

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { uniq } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
@ -19,7 +19,7 @@ async function updateContractTags() {
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
const tags = _.uniq([
const tags = uniq([
...parseTags(contract.question + contract.description),
...(contract.tags ?? []),
])

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash'
import { partition, sumBy } from 'lodash'
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
@ -51,15 +51,15 @@ export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall(
contractDoc.collection('bets').where('userId', '==', userId)
)
const prevLoanAmount = _.sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const [yesBets, noBets] = _.partition(
const [yesBets, noBets] = partition(
userBets ?? [],
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares),
sumBy(yesBets, (bet) => bet.shares),
sumBy(noBets, (bet) => bet.shares),
]
const maxShares = outcome === 'YES' ? yesShares : noShares

View File

@ -5,11 +5,13 @@ import Stripe from 'stripe'
import { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails'
export type StripeSession = Stripe.Event.Data.Object & { id: any, metadata: any}
export type StripeTransaction = {
userId: string
manticDollarQuantity: number
sessionId: string
session: any
session: StripeSession
timestamp: number
}
@ -96,14 +98,14 @@ export const stripeWebhook = functions
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as any
const session = event.data.object as StripeSession
await issueMoneys(session)
}
res.status(200).send('success')
})
const issueMoneys = async (session: any) => {
const issueMoneys = async (session: StripeSession) => {
const { id: sessionId } = session
const query = await firestore

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getUser } from './utils'
import { PrivateUser } from '../../common/user'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sumBy } from 'lodash'
import { getValues } from './utils'
import { Contract } from '../../common/contract'
@ -39,5 +39,5 @@ const computeVolumeFrom = async (contract: Contract, timeAgoMs: number) => {
.where('createdTime', '>', Date.now() - timeAgoMs)
)
return _.sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount)))
return sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount)))
}

View File

@ -1,6 +1,6 @@
import * as _ from 'lodash'
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { shuffle, sortBy } from 'lodash'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
@ -30,7 +30,7 @@ const BATCH_SIZE = 30
const MAX_BATCHES = 50
const getUserBatches = async () => {
const users = _.shuffle(await getValues<User>(firestore.collection('users')))
const users = shuffle(await getValues<User>(firestore.collection('users')))
let userBatches: User[][] = []
for (let i = 0; i < users.length; i += BATCH_SIZE) {
userBatches.push(users.slice(i, i + BATCH_SIZE))
@ -42,7 +42,7 @@ const getUserBatches = async () => {
}
export const updateFeed = functions.pubsub
.schedule('every 15 minutes')
.schedule('every 60 minutes')
.onRun(async () => {
const userBatches = await getUserBatches()
@ -128,7 +128,7 @@ export const computeFeed = async (user: User, contracts: Contract[]) => {
return [contract, score] as [Contract, number]
})
const sortedContracts = _.sortBy(
const sortedContracts = sortBy(
scoredContracts,
([_, score]) => score
).reverse()

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { sum, sumBy } from 'lodash'
import { getValues } from './utils'
import { Contract } from '../../common/contract'
@ -19,7 +19,7 @@ export const updateUserMetrics = functions.pubsub
getValues<Contract>(firestore.collection('contracts')),
])
const contractsDict = _.fromPairs(
const contractsDict = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
@ -43,12 +43,12 @@ export const updateUserMetrics = functions.pubsub
const computeInvestmentValue = async (
user: User,
contractsDict: _.Dictionary<Contract>
contractsDict: { [k: string]: Contract }
) => {
const query = firestore.collectionGroup('bets').where('userId', '==', user.id)
const bets = await getValues<Bet>(query)
return _.sumBy(bets, (bet) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
@ -60,20 +60,20 @@ const computeInvestmentValue = async (
const computeTotalPool = async (
user: User,
contractsDict: _.Dictionary<Contract>
contractsDict: { [k: string]: Contract }
) => {
const creatorContracts = Object.values(contractsDict).filter(
(contract) => contract.creatorId === user.id
)
const pools = creatorContracts.map((contract) =>
_.sum(Object.values(contract.pool))
sum(Object.values(contract.pool))
)
return _.sum(pools)
return sum(pools)
}
const computeVolume = async (contract: Contract) => {
const bets = await getValues<Bet>(
firestore.collection(`contracts/${contract.id}/bets`)
)
return _.sumBy(bets, (bet) => Math.abs(bet.amount))
return sumBy(bets, (bet) => Math.abs(bet.amount))
}

View File

@ -8,5 +8,12 @@
],
"scripts": {},
"dependencies": {},
"devDependencies": {}
"devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0",
"prettier": "2.5.0",
"typescript": "4.6.4"
}
}

View File

@ -1,9 +1,21 @@
module.exports = {
parser: '@typescript-eslint/parser',
extends: ['plugin:react-hooks/recommended', 'plugin:@next/next/recommended'],
plugins: ['lodash'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@next/next/recommended',
],
rules: {
// Add or disable rules here.
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@next/next/no-img-element': 'off',
'@next/next/no-typos': 'off',
'lodash/import-scope': [2, 'member'],
},
env: {
browser: true,
node: true,
},
}

View File

@ -1,7 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx pretty-quick --staged
# Disable tsc lint for now, cuz it's been annoying
# cd web
# npx lint-staged

View File

@ -1,3 +1,4 @@
import { ReactNode } from 'react'
import Head from 'next/head'
export type OgCardProps = {
@ -35,7 +36,7 @@ export function SEO(props: {
title: string
description: string
url?: string
children?: any[]
children?: ReactNode
ogCardProps?: OgCardProps
}) {
const { title, description, url, children, ogCardProps } = props

View File

@ -4,7 +4,7 @@ import { useState } from 'react'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user'
import { addLiquidity } from 'web/lib/firebase/api-call'
import { addLiquidity } from 'web/lib/firebase/fn-call'
import { AmountInput } from './amount-input'
import { Row } from './layout/row'

View File

@ -1,7 +1,7 @@
import clsx from 'clsx'
import { useState } from 'react'
import { useState, ReactNode } from 'react'
export function AdvancedPanel(props: { children: any }) {
export function AdvancedPanel(props: { children: ReactNode }) {
const { children } = props
const [collapsed, setCollapsed] = useState(true)

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import React from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'

View File

@ -1,7 +1,8 @@
import { ResponsiveLine } from '@nivo/line'
import { Point, ResponsiveLine } from '@nivo/line'
import dayjs from 'dayjs'
import _ from 'lodash'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Col } from '../layout/col'
export function DailyCountChart(props: {
startDate: number
@ -15,7 +16,7 @@ export function DailyCountChart(props: {
dayjs(startDate).add(i, 'day').toDate()
)
const points = _.zip(dates, dailyCounts).map(([date, betCount]) => ({
const points = zip(dates, dailyCounts).map(([date, betCount]) => ({
x: date,
y: betCount,
}))
@ -46,6 +47,10 @@ export function DailyCountChart(props: {
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/>
</div>
)
@ -63,7 +68,7 @@ export function DailyPercentChart(props: {
dayjs(startDate).add(i, 'day').toDate()
)
const points = _.zip(dates, dailyPercent).map(([date, betCount]) => ({
const points = zip(dates, dailyPercent).map(([date, betCount]) => ({
x: date,
y: betCount,
}))
@ -97,7 +102,28 @@ export function DailyPercentChart(props: {
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/>
</div>
)
}
function Tooltip(props: { point: Point }) {
const { point } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
</div>
<div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col>
)
}

View File

@ -6,7 +6,7 @@ import { Answer } from 'common/answer'
import { DPM, FreeResponse, FullContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col'
import { placeBet } from 'web/lib/firebase/api-call'
import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import {
@ -52,22 +52,26 @@ export function AnswerBetPanel(props: {
setError(undefined)
setIsSubmitting(true)
const result = await placeBet({
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)
setBetAmount(undefined)
props.closePanel()
} else {
setError(result?.message || 'Error placing bet')
setIsSubmitting(false)
}
})
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setBetAmount(undefined)
props.closePanel()
})
.catch((e) => {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
}
const betDisabled = isSubmitting || !betAmount || error

View File

@ -1,10 +1,10 @@
import clsx from 'clsx'
import _ from 'lodash'
import { sum, mapValues } from 'lodash'
import { useState } from 'react'
import { DPM, FreeResponse, FullContract } from 'common/contract'
import { Col } from '../layout/col'
import { resolveMarket } from 'web/lib/firebase/api-call'
import { resolveMarket } from 'web/lib/firebase/fn-call'
import { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button'
@ -30,8 +30,8 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(true)
const totalProb = _.sum(Object.values(chosenAnswers))
const normalizedProbs = _.mapValues(
const totalProb = sum(Object.values(chosenAnswers))
const normalizedProbs = mapValues(
chosenAnswers,
(prob) => (100 * prob) / totalProb
)

View File

@ -1,7 +1,7 @@
import { DatumValue } from '@nivo/core'
import { ResponsiveLine } from '@nivo/line'
import dayjs from 'dayjs'
import _ from 'lodash'
import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react'
import { Bet } from 'common/bet'
@ -48,7 +48,7 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
// to the right.
latestTime.add(1, 'month').valueOf()
const times = _.sortBy([
const times = sortBy([
createdTime,
...bets.map((bet) => bet.createdTime),
endTime,
@ -167,7 +167,7 @@ const computeProbsByOutcome = (
) => {
const { totalBets } = contract
const betsByOutcome = _.groupBy(bets, (bet) => bet.outcome)
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter)
@ -175,15 +175,15 @@ const computeProbsByOutcome = (
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
})
const trackedOutcomes = _.sortBy(
const trackedOutcomes = sortBy(
outcomes,
(outcome) => -1 * getOutcomeProbability(contract, outcome)
).slice(0, NUM_LINES)
const probsByOutcome = _.fromPairs(
const probsByOutcome = Object.fromEntries(
trackedOutcomes.map((outcome) => [outcome, [] as number[]])
)
const sharesByOutcome = _.fromPairs(
const sharesByOutcome = Object.fromEntries(
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
)
@ -191,7 +191,7 @@ const computeProbsByOutcome = (
const { outcome, shares } = bet
sharesByOutcome[outcome] += shares
const sharesSquared = _.sumBy(
const sharesSquared = sumBy(
Object.values(sharesByOutcome).map((shares) => shares ** 2)
)

View File

@ -1,5 +1,5 @@
import _ from 'lodash'
import React, { useLayoutEffect, useState } from 'react'
import { sortBy, partition, sum, uniq } from 'lodash'
import { useLayoutEffect, useState } from 'react'
import { DPM, FreeResponse, FullContract } from 'common/contract'
import { Col } from '../layout/col'
@ -32,7 +32,7 @@ export function AnswersPanel(props: {
const { creatorId, resolution, resolutions, totalBets } = contract
const answers = useAnswers(contract.id) ?? contract.answers
const [winningAnswers, losingAnswers] = _.partition(
const [winningAnswers, losingAnswers] = partition(
answers.filter(
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
),
@ -40,10 +40,10 @@ export function AnswersPanel(props: {
answer.id === resolution || (resolutions && resolutions[answer.id])
)
const sortedAnswers = [
..._.sortBy(winningAnswers, (answer) =>
...sortBy(winningAnswers, (answer) =>
resolutions ? -1 * resolutions[answer.id] : 0
),
..._.sortBy(
...sortBy(
resolution ? [] : losingAnswers,
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
),
@ -58,7 +58,7 @@ export function AnswersPanel(props: {
[answerId: string]: number
}>({})
const chosenTotal = _.sum(Object.values(chosenAnswers))
const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems(
contract,
@ -158,10 +158,10 @@ function getAnswerItems(
answers: Answer[],
user: User | undefined | null
) {
let outcomes = _.uniq(
answers.map((answer) => answer.number.toString())
).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001)
outcomes = _.sortBy(outcomes, (outcome) =>
let outcomes = uniq(answers.map((answer) => answer.number.toString())).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001
)
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
).reverse()

View File

@ -5,7 +5,7 @@ import Textarea from 'react-expanding-textarea'
import { DPM, FreeResponse, FullContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col'
import { createAnswer } from 'web/lib/firebase/api-call'
import { createAnswer } from 'web/lib/firebase/fn-call'
import { Row } from '../layout/row'
import {
formatMoney,

View File

@ -1,5 +1,6 @@
import Router from 'next/router'
import clsx from 'clsx'
import { MouseEvent } from 'react'
import { UserCircleIcon } from '@heroicons/react/solid'
export function Avatar(props: {
@ -15,7 +16,7 @@ export function Avatar(props: {
const onClick =
noLink && username
? undefined
: (e: any) => {
: (e: MouseEvent) => {
e.stopPropagation()
Router.push(`/${username}`)
}

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import _ from 'lodash'
import React, { useEffect, useState } from 'react'
import { partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { Binary, CPMM, DPM, FullContract } from 'common/contract'
@ -14,9 +14,10 @@ import {
formatWithCommas,
} from 'common/util/format'
import { Title } from './title'
import { firebaseLogin, User } from 'web/lib/firebase/users'
import { User } from 'web/lib/firebase/users'
import { Bet } from 'common/bet'
import { placeBet, sellShares } from 'web/lib/firebase/api-call'
import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { sellShares } from 'web/lib/firebase/fn-call'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label'
@ -35,6 +36,7 @@ import {
} from 'common/calculate-cpmm'
import { SellRow } from './sell-row'
import { useSaveShares } from './use-save-shares'
import { SignUpPrompt } from './sign-up-prompt'
export function BetPanel(props: {
contract: FullContract<DPM | CPMM, Binary>
@ -69,14 +71,7 @@ export function BetPanel(props: {
<BuyPanel contract={contract} user={user} />
{user === null && (
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up to bet!
</button>
)}
<SignUpPrompt />
</Col>
</Col>
)
@ -182,14 +177,7 @@ export function BetPanelSwitcher(props: {
/>
)}
{user === null && (
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up to bet!
</button>
)}
<SignUpPrompt />
</Col>
</Col>
)
@ -240,23 +228,27 @@ function BuyPanel(props: {
setError(undefined)
setIsSubmitting(true)
const result = await placeBet({
placeBet({
amount: betAmount,
outcome: betChoice,
contractId: contract.id,
}).then((r) => r.data as any)
console.log('placed bet. Result:', result)
if (result?.status === 'success') {
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
if (onBuySuccess) onBuySuccess()
} else {
setError(result?.message || 'Error placing bet')
setIsSubmitting(false)
}
})
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
if (onBuySuccess) onBuySuccess()
})
.catch((e) => {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
}
const betDisabled = isSubmitting || !betAmount || error
@ -436,13 +428,13 @@ export function SellPanel(props: {
const resultProb = getCpmmProbability(newPool, contract.p)
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = _.partition(
const [yesBets, noBets] = partition(
openUserBets,
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares),
sumBy(yesBets, (bet) => bet.shares),
sumBy(noBets, (bet) => bet.shares),
]
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined

View File

@ -1,5 +1,13 @@
import Link from 'next/link'
import _ from 'lodash'
import {
uniq,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
throttle,
} from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
@ -22,7 +30,7 @@ import {
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { UserLink } from './user-page'
import { sellBet } from 'web/lib/firebase/api-call'
import { sellBet } from 'web/lib/firebase/fn-call'
import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
import { filterDefined } from 'common/util/array'
@ -39,14 +47,14 @@ import {
} from 'common/calculate'
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'closed' | 'resolved' | 'all'
export function BetsList(props: { user: User }) {
const { user } = props
const bets = useUserBets(user.id)
const bets = useUserBets(user.id, { includeRedemptions: true })
const [contracts, setContracts] = useState<Contract[] | undefined>()
const [sort, setSort] = useState<BetSort>('newest')
@ -54,7 +62,7 @@ export function BetsList(props: { user: User }) {
useEffect(() => {
if (bets) {
const contractIds = _.uniq(bets.map((bet) => bet.contractId))
const contractIds = uniq(bets.map((bet) => bet.contractId))
let disposed = false
Promise.all(contractIds.map((id) => getContractFromId(id))).then(
@ -84,10 +92,10 @@ export function BetsList(props: { user: User }) {
if (bets.length === 0) return <NoBets />
// Decending creation time.
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
const contractBets = _.groupBy(bets, 'contractId')
const contractsById = _.fromPairs(contracts.map((c) => [c.id, c]))
const contractBets = groupBy(bets, 'contractId')
const contractsById = Object.fromEntries(contracts.map((c) => [c.id, c]))
const contractsMetrics = _.mapValues(contractBets, (bets, contractId) => {
const contractsMetrics = mapValues(contractBets, (bets, contractId) => {
const contract = contractsById[contractId]
if (!contract) return getContractBetNullMetrics()
return getContractBetMetrics(contract, bets)
@ -110,7 +118,7 @@ export function BetsList(props: { user: User }) {
(filter === 'open' ? -1 : 1) *
(c.resolutionTime ?? c.closeTime ?? Infinity),
}
const displayedContracts = _.sortBy(contracts, SORTS[sort])
const displayedContracts = sortBy(contracts, SORTS[sort])
.reverse()
.filter(FILTERS[filter])
.filter((c) => {
@ -121,20 +129,20 @@ export function BetsList(props: { user: User }) {
return metrics.payout > 0
})
const [settled, unsettled] = _.partition(
const [settled, unsettled] = partition(
contracts,
(c) => c.isResolved || contractsMetrics[c.id].invested === 0
)
const currentInvested = _.sumBy(
const currentInvested = sumBy(
unsettled,
(c) => contractsMetrics[c.id].invested
)
const currentBetsValue = _.sumBy(
const currentBetsValue = sumBy(
unsettled,
(c) => contractsMetrics[c.id].payout
)
const currentNetInvestment = _.sumBy(
const currentNetInvestment = sumBy(
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
@ -228,6 +236,8 @@ function MyContractBets(props: {
const { bets, contract, metric } = props
const { resolution, outcomeType } = contract
const resolutionValue = (contract as NumericContract).resolutionValue
const [collapsed, setCollapsed] = useState(true)
const isBinary = outcomeType === 'BINARY'
@ -273,6 +283,7 @@ function MyContractBets(props: {
Resolved{' '}
<OutcomeLabel
outcome={resolution}
value={resolutionValue}
contract={contract}
truncate="short"
/>
@ -327,16 +338,20 @@ export function MyBetsSummary(props: {
bets: Bet[]
className?: string
}) {
const { bets, contract, className } = props
const { contract, className } = props
const { resolution, outcomeType, mechanism } = contract
const isBinary = outcomeType === 'BINARY'
const isCpmm = mechanism === 'cpmm-1'
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = _.sumBy(excludeSales, (bet) =>
const bets = props.bets.filter((b) => !b.isAnte)
const excludeSalesAndAntes = bets.filter(
(b) => !b.isAnte && !b.isSold && !b.sale
)
const yesWinnings = sumBy(excludeSalesAndAntes, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = _.sumBy(excludeSales, (bet) =>
const noWinnings = sumBy(excludeSalesAndAntes, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const { invested, profitPercent, payout, profit } = getContractBetMetrics(
@ -410,29 +425,30 @@ export function ContractBetsTable(props: {
bets: Bet[]
className?: string
}) {
const { contract, bets, className } = props
const { contract, className } = props
const [sales, buys] = _.partition(bets, (bet) => bet.sale)
const bets = props.bets.filter((b) => !b.isAnte)
const salesDict = _.fromPairs(
const [sales, buys] = partition(bets, (bet) => bet.sale)
const salesDict = Object.fromEntries(
sales.map((sale) => [sale.sale?.betId ?? '', sale])
)
const [redemptions, normalBets] = _.partition(
const [redemptions, normalBets] = partition(
contract.mechanism === 'cpmm-1' ? bets : buys,
(b) => b.isRedemption
)
const amountRedeemed = Math.floor(
-0.5 * _.sumBy(redemptions, (b) => b.shares)
)
const amountRedeemed = Math.floor(-0.5 * sumBy(redemptions, (b) => b.shares))
const amountLoaned = _.sumBy(
const amountLoaned = sumBy(
bets.filter((bet) => !bet.isSold && !bet.sale),
(bet) => bet.loanAmount ?? 0
)
const { isResolved, mechanism } = contract
const { isResolved, mechanism, outcomeType } = contract
const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC'
return (
<div className={clsx('overflow-x-auto', className)}>
@ -462,7 +478,9 @@ export function ContractBetsTable(props: {
{isCPMM && <th>Type</th>}
<th>Outcome</th>
<th>Amount</th>
{!isCPMM && <th>{isResolved ? <>Payout</> : <>Sale price</>}</th>}
{!isCPMM && !isNumeric && (
<th>{isResolved ? <>Payout</> : <>Sale price</>}</th>
)}
{!isCPMM && !isResolved && <th>Payout if chosen</th>}
<th>Shares</th>
<th>Probability</th>
@ -497,11 +515,12 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
isAnte,
} = bet
const { isResolved, closeTime, mechanism } = contract
const { isResolved, closeTime, mechanism, outcomeType } = contract
const isClosed = closeTime && Date.now() > closeTime
const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC'
const saleAmount = saleBet?.sale?.amount
@ -518,31 +537,35 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
)
const payoutIfChosenDisplay =
bet.outcome === '0' && bet.isAnte
bet.isAnte && outcomeType === 'FREE_RESPONSE' && bet.outcome === '0'
? 'N/A'
: formatMoney(calculatePayout(contract, bet, bet.outcome))
return (
<tr>
<td className="text-neutral">
{!isCPMM && !isResolved && !isClosed && !isSold && !isAnte && (
<SellButton contract={contract} bet={bet} />
)}
{!isCPMM &&
!isResolved &&
!isClosed &&
!isSold &&
!isAnte &&
!isNumeric && <SellButton contract={contract} bet={bet} />}
</td>
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
<td>
{outcome === '0' ? (
{bet.isAnte ? (
'ANTE'
) : (
<OutcomeLabel
outcome={outcome}
value={(bet as any).value}
contract={contract}
truncate="short"
/>
)}
</td>
<td>{formatMoney(Math.abs(amount))}</td>
{!isCPMM && <td>{saleDisplay}</td>}
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
<td>{formatWithCommas(Math.abs(shares))}</td>
<td>
@ -553,10 +576,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
)
}
const warmUpSellBet = _.throttle(
() => sellBet({}).catch(() => {}),
5000 /* ms */
)
const warmUpSellBet = throttle(() => sellBet({}).catch(() => {}), 5000 /* ms */)
function SellButton(props: { contract: Contract; bet: Bet }) {
useEffect(() => {

View File

@ -0,0 +1,42 @@
import { useState } from 'react'
import { NumericContract } from 'common/contract'
import { getMappedBucket } from 'common/calculate-dpm'
import { NumberInput } from './number-input'
export function BucketInput(props: {
contract: NumericContract
isSubmitting?: boolean
onBucketChange: (value?: number, bucket?: string) => void
}) {
const { contract, isSubmitting, onBucketChange } = props
const [numberString, setNumberString] = useState('')
const onChange = (s: string) => {
setNumberString(s)
const value = parseFloat(s)
if (!isFinite(value)) {
onBucketChange(undefined, undefined)
return
}
const bucket = getMappedBucket(value, contract)
onBucketChange(value, bucket)
}
return (
<NumberInput
inputClassName="w-full max-w-none"
onChange={onChange}
error={undefined}
disabled={isSubmitting}
numberString={numberString}
label="Value"
/>
)
}

View File

@ -1,5 +1,5 @@
import { StarIcon } from '@heroicons/react/solid'
import _ from 'lodash'
import { sumBy } from 'lodash'
import Link from 'next/link'
import Image from 'next/image'
import { Charity } from 'common/charity'
@ -11,7 +11,7 @@ export function CharityCard(props: { charity: Charity }) {
const { name, slug, photo, preview, id, tags } = props.charity
const txns = useCharityTxns(id)
const raised = _.sumBy(txns, (txn) => txn.amount)
const raised = sumBy(txns, (txn) => txn.amount)
return (
<Link href={`/charity/${slug}`} passHref>

View File

@ -0,0 +1,56 @@
import { Row } from './layout/row'
import { RadioGroup } from '@headlessui/react'
import clsx from 'clsx'
import React from 'react'
export function ChoicesToggleGroup(props: {
currentChoice: number | string
choicesMap: { [key: string]: string | number }
isSubmitting?: boolean
setChoice: (p: number | string) => void
className?: string
children?: React.ReactNode
}) {
const {
currentChoice,
setChoice,
isSubmitting,
choicesMap,
className,
children,
} = props
return (
<Row className={'mt-2 items-center gap-2'}>
<RadioGroup
value={currentChoice.toString()}
onChange={(str) => null}
className="mt-2"
>
<div className={`grid grid-cols-12 gap-3`}>
{Object.keys(choicesMap).map((choiceKey) => (
<RadioGroup.Option
key={choiceKey}
value={choicesMap[choiceKey]}
onClick={() => setChoice(choicesMap[choiceKey])}
className={({ active }) =>
clsx(
active ? 'ring-2 ring-indigo-500 ring-offset-2' : '',
currentChoice === choicesMap[choiceKey]
? 'border-transparent bg-indigo-500 text-white hover:bg-indigo-600'
: 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case',
"hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500",
className
)
}
disabled={isSubmitting}
>
<RadioGroup.Label as="span">{choiceKey}</RadioGroup.Label>
</RadioGroup.Option>
))}
{children}
</div>
</RadioGroup>
</Row>
)
}

View File

@ -1,5 +1,5 @@
// Adapted from https://stackoverflow.com/a/50884055/1222351
import { useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
export function ClientRender(props: { children: React.ReactNode }) {
const { children } = props

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useState } from 'react'
import { ReactNode, useState } from 'react'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
@ -20,7 +20,7 @@ export function ConfirmationButton(props: {
className?: string
}
onSubmit: () => void
children: any
children: ReactNode
}) {
const { id, openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite'
import {
InstantSearch,
@ -8,7 +9,6 @@ import {
useRange,
useRefinementList,
useSortBy,
useToggleRefinement,
} from 'react-instantsearch-hooks-web'
import { Contract } from '../../common/contract'
import {
@ -37,6 +37,7 @@ const sortIndexes = [
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
]
@ -82,54 +83,49 @@ export function ContractSearch(props: {
additionalFilter?.tag ?? additionalFilter?.creatorId ?? ''
}`}
>
<Row className="flex-wrap gap-2">
<Row className="gap-1 sm:gap-2">
<SearchBox
className="flex-1"
classNames={{
form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none',
resetIcon: 'mt-2',
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
resetIcon: 'mt-2 hidden sm:flex',
}}
/>
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
placeholder="Search markets"
/>
<Row className="mt-2 gap-2 sm:mt-0">
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
/>
</Row>
</Row>
<div>
{showCategorySelector && (
<>
<Spacer h={4} />
<CategorySelector
user={user}
category={category}
setCategory={setCategory}
/>
</>
)}
<Spacer h={4} />
<ContractSearchInner
querySortOptions={querySortOptions}
filter={filter}
additionalFilter={{ category, ...additionalFilter }}
<Spacer h={3} />
{showCategorySelector && (
<CategorySelector
className="mb-2"
user={user}
category={category}
setCategory={setCategory}
/>
</div>
)}
<ContractSearchInner
querySortOptions={querySortOptions}
filter={filter}
additionalFilter={{ category, ...additionalFilter }}
/>
</InstantSearch>
)
}
@ -195,20 +191,23 @@ export function ContractSearchInner(props: {
filter === 'resolved' ? true : filter === 'all' ? undefined : false
)
const { showMore, hits, isLastPage, results } = useInfiniteHits()
const [isInitialLoad, setIsInitialLoad] = useState(true)
useEffect(() => {
const id = setTimeout(() => setIsInitialLoad(false), 1000)
return () => clearTimeout(id)
}, [])
const { showMore, hits, isLastPage } = useInfiniteHits()
const contracts = hits as any as Contract[]
const router = useRouter()
const hasLoaded = contracts.length > 0 || router.isReady
if (!hasLoaded || !results) return <></>
if (isInitialLoad && contracts.length === 0) return <></>
return (
<ContractsGrid
contracts={contracts}
loadMore={showMore}
hasMore={!isLastPage}
showCloseTime={index === 'contracts-closing-soon'}
showCloseTime={index.endsWith('close-date')}
/>
)
}
@ -242,13 +241,16 @@ const useFilterClosed = (value: boolean | undefined) => {
}
const useFilterResolved = (value: boolean | undefined) => {
// Note (James): I don't know why this works.
const { refine: refineResolved } = useToggleRefinement({
attribute: value === undefined ? 'non-existant-field' : 'isResolved',
on: true,
off: value === undefined ? undefined : false,
const { items, refine: deleteRefinement } = useCurrentRefinements({
includedAttributes: ['isResolved'],
})
const { refine } = useRefinementList({ attribute: 'isResolved' })
useEffect(() => {
refineResolved({ isRefined: !value })
const refinements = items[0]?.refinements ?? []
if (value !== undefined) refine(`${value}`)
refinements.forEach((refinement) => deleteRefinement(refinement))
}, [value])
}

View File

@ -1,12 +1,11 @@
import clsx from 'clsx'
import Link from 'next/link'
import { Row } from '../layout/row'
import { formatPercent } from 'common/util/format'
import { formatLargeNumber, formatPercent } from 'common/util/format'
import {
Contract,
contractPath,
getBinaryProbPercent,
getBinaryProb,
} from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import {
@ -16,47 +15,19 @@ import {
FreeResponse,
FreeResponseContract,
FullContract,
NumericContract,
} from 'common/contract'
import {
AnswerLabel,
BinaryContractOutcomeLabel,
CancelLabel,
FreeResponseOutcomeLabel,
OUTCOME_TO_COLOR,
} from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
import { AbbrContractDetails } from './contract-details'
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) {
const { outcomeType, resolution } = contract
return resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: 1 // Should not happen
}
function getColor(contract: Contract) {
const { resolution } = contract
if (resolution) {
return (
// @ts-ignore; TODO: Have better typing for contract.resolution?
OUTCOME_TO_COLOR[resolution] ||
// If resolved to a FR answer, use 'primary'
'primary'
)
}
const marketClosed = (contract.closeTime || Infinity) < Date.now()
return marketClosed
? 'gray-400'
: getProb(contract) >= 0.5
? 'primary'
: 'red-400'
}
import { AvatarDetails, MiscDetails } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract'
export function ContractCard(props: {
contract: Contract
@ -64,73 +35,95 @@ export function ContractCard(props: {
showCloseTime?: boolean
className?: string
}) {
const { contract, showHotVolume, showCloseTime, className } = props
const { showHotVolume, showCloseTime, className } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
const { resolution } = contract
const prob = getProb(contract)
const color = getColor(contract)
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const showTopBar = prob >= 0.5 || marketClosed
const showQuickBet = !(
marketClosed ||
(outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined)
)
return (
<div>
<Col
className={clsx(
'relative gap-3 rounded-lg bg-white p-6 pr-7 shadow-md hover:bg-gray-100',
'relative gap-3 rounded-lg bg-white py-4 pl-6 pr-5 shadow-md hover:cursor-pointer hover:bg-gray-100',
className
)}
>
<Link href={contractPath(contract)}>
<a className="absolute left-0 right-0 top-0 bottom-0" />
</Link>
<AbbrContractDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
<Row className={clsx('justify-between gap-4')}>
<Col className="gap-3">
<Row>
<Col className="relative flex-1 gap-3 pr-1">
<div
className={clsx(
'peer absolute -left-6 -top-4 -bottom-4 z-10',
// Hack: Extend the clickable area for closed markets
showQuickBet ? 'right-0' : 'right-[-6.5rem]'
)}
>
<Link href={contractPath(contract)}>
<a className="absolute top-0 left-0 right-0 bottom-0" />
</Link>
</div>
<AvatarDetails contract={contract} />
<p
className="break-words font-medium text-indigo-700"
className="break-words font-semibold text-indigo-700 peer-hover:underline peer-hover:decoration-indigo-400 peer-hover:decoration-2"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
{question}
</p>
</Col>
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
{outcomeType === 'FREE_RESPONSE' &&
(resolution ? (
<FreeResponseOutcomeLabel
contract={contract as FreeResponseContract}
resolution={resolution}
truncate={'long'}
/>
) : (
<FreeResponseTopAnswer
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
))}
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
</Col>
{showQuickBet ? (
<QuickBet contract={contract} />
) : (
<Col className="m-auto pl-2">
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
className="items-center"
contract={contract as NumericContract}
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
<ProbBar contract={contract} />
</Col>
)}
</Row>
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
<div
className={clsx(
'absolute right-0 top-0 w-2 rounded-tr-md',
'bg-gray-200'
)}
style={{ height: `${100 * (1 - prob)}%` }}
></div>
<div
className={clsx(
'absolute right-0 bottom-0 w-2 rounded-br-md',
`bg-${color}`,
// If we're showing the full bar, also round the top
prob === 1 ? 'rounded-tr-md' : ''
)}
style={{ height: `${100 * prob}%` }}
></div>
</Col>
</div>
)
@ -171,6 +164,24 @@ export function BinaryResolutionOrChance(props: {
)
}
function FreeResponseTopAnswer(props: {
contract: FreeResponseContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
const { contract, truncate } = props
const topAnswer = getTopAnswer(contract)
return topAnswer ? (
<AnswerLabel
className="!text-gray-600"
answer={topAnswer}
truncate={truncate}
/>
) : null
}
export function FreeResponseResolutionOrChance(props: {
contract: FreeResponseContract
truncate: 'short' | 'long' | 'none'
@ -186,22 +197,21 @@ export function FreeResponseResolutionOrChance(props: {
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
<FreeResponseOutcomeLabel
contract={contract}
resolution={resolution}
truncate={truncate}
answerClassName="text-xl"
/>
<div className={clsx('text-base text-gray-500 sm:hidden')}>
Resolved
</div>
{(resolution === 'CANCEL' || resolution === 'MKT') && (
<FreeResponseOutcomeLabel
contract={contract}
resolution={resolution}
truncate={truncate}
answerClassName="text-3xl uppercase text-blue-500"
/>
)}
</>
) : (
topAnswer && (
<Row className="items-center gap-6">
<AnswerLabel
className="!text-gray-600"
answer={topAnswer}
truncate={truncate}
/>
<Col className={clsx('text-3xl', textColor)}>
<div>
{formatPercent(getOutcomeProbability(contract, topAnswer.id))}
@ -214,3 +224,38 @@ export function FreeResponseResolutionOrChance(props: {
</Col>
)
}
export function NumericResolutionOrExpectation(props: {
contract: NumericContract
className?: string
}) {
const { contract, className } = props
const { resolution } = contract
const textColor = `text-${getColor(contract)}`
const resolutionValue =
contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract)
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">{resolutionValue}</div>
)}
</>
) : (
<>
<div className={clsx('text-3xl', textColor)}>
{formatLargeNumber(getExpectedValue(contract))}
</div>
<div className={clsx('text-base', textColor)}>expected</div>
</>
)}
</Col>
)
}

View File

@ -5,13 +5,16 @@ import {
PencilIcon,
CurrencyDollarIcon,
TrendingUpIcon,
StarIcon,
} from '@heroicons/react/outline'
import { StarIcon as SolidStarIcon } from '@heroicons/react/solid'
import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format'
import { UserLink } from '../user-page'
import {
Contract,
contractMetrics,
contractPool,
updateContract,
} from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
@ -26,20 +29,13 @@ import NewContractBadge from '../new-contract-badge'
import { CATEGORY_LIST } from 'common/categories'
import { TagsList } from '../tags-list'
export function AbbrContractDetails(props: {
export function MiscDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
}) {
const { contract, showHotVolume, showCloseTime } = props
const {
volume,
volume24Hours,
creatorName,
creatorUsername,
closeTime,
tags,
} = contract
const { volume, volume24Hours, closeTime, tags } = contract
const { volumeLabel } = contractMetrics(contract)
// Show at most one category that this contract is tagged by
const categories = CATEGORY_LIST.filter((category) =>
@ -47,45 +43,62 @@ export function AbbrContractDetails(props: {
).slice(0, 1)
return (
<Col className={clsx('gap-2 text-sm text-gray-500')}>
<Row className="items-center justify-between">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
<Row className="items-center gap-3 text-sm text-gray-400">
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
<Row className="gap-3 text-gray-400">
{categories.length > 0 && (
<TagsList className="text-gray-400" tags={categories} noLabel />
)}
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" />{' '}
{formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-0.5">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : volume > 0 ? (
<Row>{volumeLabel}</Row>
) : (
<NewContractBadge />
)}
) : showCloseTime ? (
<Row className="gap-0.5">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
</Row>
</Col>
) : volume > 0 ? (
<Row>{contractPool(contract)} pool</Row>
) : (
<NewContractBadge />
)}
{categories.length > 0 && (
<TagsList className="text-gray-400" tags={categories} noLabel />
)}
</Row>
)
}
export function AvatarDetails(props: { contract: Contract }) {
const { contract } = props
const { creatorName, creatorUsername } = contract
return (
<Row className="items-center gap-2 text-sm text-gray-400">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink name={creatorName} username={creatorUsername} />
</Row>
)
}
export function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
}) {
const { contract, showHotVolume, showCloseTime } = props
return (
<Row className="items-center justify-between">
<AvatarDetails contract={contract} />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
</Row>
)
}
@ -97,7 +110,7 @@ export function ContractDetails(props: {
}) {
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername } = contract
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract)
const { volumeLabel, resolvedDate } = contractMetrics(contract)
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
@ -191,7 +204,7 @@ function EditableCloseDate(props: {
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
const [closeDate, setCloseDate] = useState(
closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59')
closeTime && dayjs(closeTime).format('YYYY-MM-DDTHH:mm')
)
const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year')

View File

@ -1,13 +1,18 @@
import { DotsHorizontalIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import dayjs from 'dayjs'
import _ from 'lodash'
import { uniqBy, sum } from 'lodash'
import { useState } from 'react'
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import {
contractMetrics,
contractPath,
contractPool,
getBinaryProbPercent,
} from 'web/lib/firebase/contracts'
import { AddLiquidityPanel } from '../add-liquidity-panel'
import { CopyLinkButton } from '../copy-link-button'
import { Col } from '../layout/col'
@ -26,7 +31,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
const { createdTime, closeTime, resolutionTime } = contract
const tradersCount = _.uniqBy(bets, 'userId').length
const tradersCount = uniqBy(bets, 'userId').length
return (
<>
@ -98,19 +103,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<td>{tradersCount}</td>
</tr>
{contract.mechanism === 'cpmm-1' && (
<tr>
<td>Liquidity</td>
<td>{formatMoney(contract.totalLiquidity)}</td>
</tr>
)}
{contract.mechanism === 'dpm-2' && (
<tr>
<td>Pool</td>
<td>{formatMoney(_.sum(Object.values(contract.pool)))}</td>
</tr>
)}
<tr>
<td>Pool</td>
<td>{contractPool(contract)}</td>
</tr>
</tbody>
</table>

View File

@ -6,18 +6,26 @@ import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row'
import { Linkify } from '../linkify'
import clsx from 'clsx'
import {
FreeResponseResolutionOrChance,
BinaryResolutionOrChance,
NumericResolutionOrExpectation,
} from './contract-card'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import BetRow from '../bet-row'
import { AnswersGraph } from '../answers/answers-graph'
import { DPM, FreeResponse, FullContract } from 'common/contract'
import {
DPM,
FreeResponse,
FullContract,
NumericContract,
} from 'common/contract'
import { ContractDescription } from './contract-description'
import { ContractDetails } from './contract-details'
import { ShareMarket } from '../share-market'
import { NumericGraph } from './numeric-graph'
export const ContractOverview = (props: {
contract: Contract
@ -47,6 +55,13 @@ export const ContractOverview = (props: {
large
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract as NumericContract}
className="hidden items-end xl:flex"
/>
)}
</Row>
{isBinary ? (
@ -65,28 +80,33 @@ export const ContractOverview = (props: {
)
)}
{outcomeType === 'NUMERIC' && (
<Row className="items-center justify-between gap-4 xl:hidden">
<NumericResolutionOrExpectation
contract={contract as NumericContract}
/>
</Row>
)}
<ContractDetails
contract={contract}
bets={bets}
isCreator={isCreator}
/>
</Col>
<Spacer h={4} />
{isBinary ? (
<ContractProbGraph contract={contract} bets={bets} />
) : (
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '}
{outcomeType === 'FREE_RESPONSE' && (
<AnswersGraph
contract={contract as FullContract<DPM, FreeResponse>}
bets={bets}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericGraph contract={contract as NumericContract} />
)}
{(contract.description || isCreator) && <Spacer h={6} />}
{isCreator && <ShareMarket className="px-2" contract={contract} />}
<ContractDescription
className="px-2"
contract={contract}

View File

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

View File

@ -19,7 +19,6 @@ export function ContractsGrid(props: {
const isBottomVisible = useIsVisible(elem)
useEffect(() => {
console.log({ isBottomVisible, hasMore })
if (isBottomVisible) {
loadMore()
}
@ -29,7 +28,7 @@ export function ContractsGrid(props: {
return (
<p className="mx-2 text-gray-500">
No markets found. Why not{' '}
<SiteLink href="/home" className="font-bold text-gray-700">
<SiteLink href="/create" className="font-bold text-gray-700">
create one?
</SiteLink>
</p>
@ -38,7 +37,7 @@ export function ContractsGrid(props: {
return (
<Col className="gap-8">
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
<ul className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
{contracts.map((contract) => (
<ContractCard
contract={contract}

View File

@ -0,0 +1,99 @@
import { DatumValue } from '@nivo/core'
import { Point, ResponsiveLine } from '@nivo/line'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { memo } from 'react'
import { range } from 'lodash'
import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm'
import { NumericContract } from '../../../common/contract'
import { useWindowSize } from '../../hooks/use-window-size'
import { Col } from '../layout/col'
import { formatLargeNumber } from 'common/util/format'
export const NumericGraph = memo(function NumericGraph(props: {
contract: NumericContract
height?: number
}) {
const { contract, height } = props
const { totalShares, bucketCount, min, max } = contract
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
const xs = range(bucketCount).map(
(i) => min + ((max - min) * i) / bucketCount
)
const probs = range(bucketCount).map((i) => bucketProbs[`${i}`] * 100)
const points = probs.map((prob, i) => ({ x: xs[i], y: prob }))
const maxProb = Math.max(...probs)
const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }]
const yTickValues = [
0,
0.25 * maxProb,
0.5 & maxProb,
0.75 * maxProb,
maxProb,
]
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: maxProb, type: 'linear' }}
yFormat={formatPercent}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'linear',
min: min,
max: max,
}}
xFormat={(d) => `${formatLargeNumber(+d, 3)}`}
axisBottom={{
tickValues: numXTickValues,
format: (d) => `${formatLargeNumber(+d, 3)}`,
}}
colors={{ datum: 'color' }}
pointSize={0}
enableSlices="x"
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 50 }}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
const p = Math.round(+y * 100) / 100
return `${p}%`
}
function Tooltip(props: { point: Point }) {
const { point } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
</div>
<div>{formatLargeNumber(+point.data.x)}</div>
</Col>
)
}

View File

@ -0,0 +1,290 @@
import clsx from 'clsx'
import {
getOutcomeProbability,
getOutcomeProbabilityAfterBet,
getTopAnswer,
} from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm'
import {
Contract,
FullContract,
CPMM,
DPM,
Binary,
NumericContract,
FreeResponseContract,
} from 'common/contract'
import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { placeBet } from 'web/lib/firebase/api-call'
import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
import { Col } from '../layout/col'
import { OUTCOME_TO_COLOR } from '../outcome-label'
import { useSaveShares } from '../use-save-shares'
const BET_SIZE = 10
export function QuickBet(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const topAnswer =
contract.outcomeType === 'FREE_RESPONSE'
? getTopAnswer(contract as FreeResponseContract)
: undefined
// TODO: yes/no from useSaveShares doesn't work on numeric contracts
const { yesFloorShares, noFloorShares } = useSaveShares(
contract as FullContract<DPM | CPMM, Binary | FreeResponseContract>,
userBets,
topAnswer?.number.toString() || undefined
)
const hasUpShares =
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC')
const hasDownShares =
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC'
const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = useState(false)
let previewProb = undefined
try {
previewProb = upHover
? getOutcomeProbabilityAfterBet(
contract,
quickOutcome(contract, 'UP') || '',
BET_SIZE
)
: downHover
? 1 -
getOutcomeProbabilityAfterBet(
contract,
quickOutcome(contract, 'DOWN') || '',
BET_SIZE
)
: undefined
} catch (e) {
// Catch any errors from hovering on an invalid option
}
const color = getColor(contract, previewProb)
async function placeQuickBet(direction: 'UP' | 'DOWN') {
const betPromise = async () => {
const outcome = quickOutcome(contract, direction)
return await placeBet({
amount: BET_SIZE,
outcome,
contractId: contract.id,
})
}
const shortQ = contract.question.slice(0, 20)
toast.promise(betPromise(), {
loading: `${formatMoney(BET_SIZE)} on "${shortQ}"...`,
success: `${formatMoney(BET_SIZE)} on "${shortQ}"...`,
error: (err) => `${err.message}`,
})
}
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
if (contract.outcomeType === 'BINARY') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (contract.outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (contract.outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
return (
<Col
className={clsx(
'relative -my-4 -mr-5 min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
// Use this for colored QuickBet panes
// `bg-opacity-10 bg-${color}`
)}
>
{/* Up bet triangle */}
<div>
<div
className="peer absolute top-0 left-0 right-0 h-[50%]"
onMouseEnter={() => setUpHover(true)}
onMouseLeave={() => setUpHover(false)}
onClick={() => placeQuickBet('UP')}
/>
<div className="mt-2 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(10)}
</div>
{hasUpShares > 0 ? (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? `text-${color}` : 'text-gray-400'
)}
/>
) : (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? `text-${color}` : 'text-gray-200'
)}
/>
)}
</div>
<QuickOutcomeView contract={contract} previewProb={previewProb} />
{/* Down bet triangle */}
<div>
<div
className="peer absolute bottom-0 left-0 right-0 h-[50%]"
onMouseEnter={() => setDownHover(true)}
onMouseLeave={() => setDownHover(false)}
onClick={() => placeQuickBet('DOWN')}
></div>
{hasDownShares > 0 ? (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5',
downHover ? `text-${color}` : 'text-gray-400'
)}
/>
) : (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5',
downHover ? `text-${color}` : 'text-gray-200'
)}
/>
)}
<div className="mb-2 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(10)}
</div>
</div>
</Col>
)
}
export function ProbBar(props: { contract: Contract; previewProb?: number }) {
const { contract, previewProb } = props
const color = getColor(contract, previewProb)
const prob = previewProb ?? getProb(contract)
return (
<>
<div
className={clsx(
'absolute right-0 top-0 w-2 rounded-tr-md transition-all',
'bg-gray-200'
)}
style={{ height: `${100 * (1 - prob)}%` }}
/>
<div
className={clsx(
'absolute right-0 bottom-0 w-2 rounded-br-md transition-all',
`bg-${color}`,
// If we're showing the full bar, also round the top
prob === 1 ? 'rounded-tr-md' : ''
)}
style={{ height: `${100 * prob}%` }}
/>
</>
)
}
function QuickOutcomeView(props: {
contract: Contract
previewProb?: number
caption?: 'chance' | 'expected'
}) {
const { contract, previewProb, caption } = props
const { outcomeType } = contract
// If there's a preview prob, display that instead of the current prob
const override =
previewProb === undefined ? undefined : formatPercent(previewProb)
const textColor = `text-${getColor(contract, previewProb)}`
let display: string | undefined
switch (outcomeType) {
case 'BINARY':
display = getBinaryProbPercent(contract)
break
case 'NUMERIC':
display = formatLargeNumber(getExpectedValue(contract as NumericContract))
break
case 'FREE_RESPONSE': {
const topAnswer = getTopAnswer(contract as FreeResponseContract)
display =
topAnswer &&
formatPercent(getOutcomeProbability(contract, topAnswer.id))
break
}
}
return (
<Col className={clsx('items-center text-3xl', textColor)}>
{override ?? display}
{caption && <div className="text-base">{caption}</div>}
<ProbBar contract={contract} previewProb={previewProb} />
</Col>
)
}
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) {
const { outcomeType, resolution } = contract
return resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC'
? getNumericScale(contract as NumericContract)
: 1 // Should not happen
}
function getNumericScale(contract: NumericContract) {
const { min, max } = contract
const ev = getExpectedValue(contract)
return (ev - min) / (max - min)
}
export function getColor(contract: Contract, previewProb?: number) {
// TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
// TODO: Try injecting a gradient here
// return 'primary'
const { resolution } = contract
if (resolution) {
return (
OUTCOME_TO_COLOR[resolution as 'YES' | 'NO' | 'CANCEL' | 'MKT'] ??
// If resolved to a FR answer, use 'primary'
'primary'
)
}
if (contract.outcomeType === 'NUMERIC') {
return 'blue-400'
}
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const prob = previewProb ?? getProb(contract)
return marketClosed ? 'gray-400' : prob >= 0.5 ? 'primary' : 'red-400'
}

View File

@ -1,3 +1,4 @@
import React from 'react'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'

View File

@ -1,4 +1,4 @@
import _ from 'lodash'
import { sample } from 'lodash'
import { SparklesIcon, XIcon } from '@heroicons/react/solid'
import { Avatar } from './avatar'
import { useEffect, useRef, useState } from 'react'
@ -30,7 +30,7 @@ export function FeedPromo(props: { hotContracts: Contract[] }) {
<div className="font-semibold sm:mb-2">
Bet on{' '}
<span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent">
any question!
anything!
</span>
</div>
</h1>
@ -86,9 +86,7 @@ export default function FeedCreate(props: {
// Take care not to produce a different placeholder on the server and client
const [defaultPlaceholder, setDefaultPlaceholder] = useState('')
useEffect(() => {
setDefaultPlaceholder(
`e.g. ${_.sample(ENV_CONFIG.newQuestionPlaceholders)}`
)
setDefaultPlaceholder(`e.g. ${sample(ENV_CONFIG.newQuestionPlaceholders)}`)
}, [])
const placeholder = props.placeholder ?? defaultPlaceholder

View File

@ -1,4 +1,4 @@
import _, { Dictionary } from 'lodash'
import { last, findLastIndex, uniq, sortBy } from 'lodash'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
@ -28,7 +28,7 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
betsByCurrentUser: Bet[]
comments: Comment[]
commentsByCurrentUser: Comment[]
answerOutcome?: string
}
@ -54,6 +54,7 @@ export type CommentItem = BaseActivityItem & {
type: 'comment'
comment: Comment
betsBySameUser: Bet[]
probAtCreatedTime?: number
truncate?: boolean
smallAvatar?: boolean
}
@ -62,7 +63,7 @@ export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread'
parentComment: Comment
comments: Comment[]
betsByUserId: Dictionary<[Bet, ...Bet[]]>
bets: Bet[]
}
export type BetGroupItem = BaseActivityItem & {
@ -76,7 +77,7 @@ export type AnswerGroupItem = BaseActivityItem & {
answer: Answer
items: ActivityItem[]
betsByCurrentUser?: Bet[]
comments?: Comment[]
commentsByCurrentUser?: Comment[]
}
export type CloseItem = BaseActivityItem & {
@ -87,7 +88,6 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve'
}
export const GENERAL_COMMENTS_OUTCOME_ID = 'General Comments'
const DAY_IN_MS = 24 * 60 * 60 * 1000
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
@ -200,17 +200,17 @@ function getAnswerGroups(
) {
const { sortByProb, abbreviated, reversed } = options
let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter(
let outcomes = uniq(bets.map((bet) => bet.outcome)).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001
)
if (abbreviated) {
const lastComment = _.last(comments)
const lastComment = last(comments)
const lastCommentOutcome = bets.find(
(bet) => bet.id === lastComment?.betId
)?.outcome
const lastBetOutcome = _.last(bets)?.outcome
const lastBetOutcome = last(bets)?.outcome
if (lastCommentOutcome && lastBetOutcome) {
outcomes = _.uniq([
outcomes = uniq([
...outcomes.filter(
(outcome) =>
outcome !== lastCommentOutcome && outcome !== lastBetOutcome
@ -222,13 +222,13 @@ function getAnswerGroups(
outcomes = outcomes.slice(-2)
}
if (sortByProb) {
outcomes = _.sortBy(outcomes, (outcome) =>
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
)
} else {
// Sort by recent bet.
outcomes = _.sortBy(outcomes, (outcome) =>
_.findLastIndex(bets, (bet) => bet.outcome === outcome)
outcomes = sortBy(outcomes, (outcome) =>
findLastIndex(bets, (bet) => bet.outcome === outcome)
)
}
@ -274,12 +274,13 @@ function getAnswerAndCommentInputGroups(
comments: Comment[],
user: User | undefined | null
) {
let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter(
let outcomes = uniq(bets.map((bet) => bet.outcome)).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001
)
outcomes = _.sortBy(outcomes, (outcome) =>
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
)
const betsByCurrentUser = bets.filter((bet) => bet.userId === user?.id)
const answerGroups = outcomes
.map((outcome) => {
@ -293,9 +294,7 @@ function getAnswerAndCommentInputGroups(
comment.answerOutcome === outcome ||
answerBets.some((bet) => bet.id === comment.betId)
)
const items = getCommentThreads(answerBets, answerComments, contract)
if (outcome === GENERAL_COMMENTS_OUTCOME_ID) items.reverse()
const items = getCommentThreads(bets, answerComments, contract)
return {
id: outcome,
@ -304,8 +303,10 @@ function getAnswerAndCommentInputGroups(
answer,
items,
user,
betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id),
comments: answerComments,
betsByCurrentUser,
commentsByCurrentUser: answerComments.filter(
(comment) => comment.userId === user?.id
),
}
})
.filter((group) => group.answer) as ActivityItem[]
@ -325,6 +326,7 @@ function groupBetsAndComments(
}
) {
const { smallAvatar, abbreviated, reversed } = options
// Comments in feed don't show user's position?
const commentsWithoutBets = comments
.filter((comment) => !comment.betId)
.map((comment) => ({
@ -341,7 +343,7 @@ function groupBetsAndComments(
// iterate through the bets and comment activity items and add them to the items in order of comment creation time:
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
let sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => {
const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => {
if (item.type === 'comment') {
return item.comment.createdTime
} else if (item.type === 'bet') {
@ -364,7 +366,6 @@ function getCommentThreads(
comments: Comment[],
contract: Contract
) {
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
const items = parentComments.map((comment) => ({
@ -373,7 +374,7 @@ function getCommentThreads(
contract: contract,
comments: comments,
parentComment: comment,
betsByUserId: betsByUserId,
bets: bets,
}))
return items
@ -433,7 +434,7 @@ export function getAllContractActivityItems(
id: 'commentInput',
contract,
betsByCurrentUser: [],
comments: [],
commentsByCurrentUser: [],
})
} else {
items.push(
@ -459,7 +460,7 @@ export function getAllContractActivityItems(
id: 'commentInput',
contract,
betsByCurrentUser: [],
comments: [],
commentsByCurrentUser: [],
})
}
@ -520,6 +521,15 @@ export function getRecentContractActivityItems(
return [questionItem, ...items]
}
function commentIsGeneralComment(comment: Comment, contract: Contract) {
return (
comment.answerOutcome === undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
? comment.betId === undefined
: true)
)
}
export function getSpecificContractActivityItems(
contract: Contract,
bets: Bet[],
@ -530,7 +540,7 @@ export function getSpecificContractActivityItems(
}
) {
const { mode } = options
let items = [] as ActivityItem[]
const items = [] as ActivityItem[]
switch (mode) {
case 'bets':
@ -549,9 +559,9 @@ export function getSpecificContractActivityItems(
)
break
case 'comments':
const nonFreeResponseComments = comments.filter(
(comment) => comment.answerOutcome === undefined
case 'comments': {
const nonFreeResponseComments = comments.filter((comment) =>
commentIsGeneralComment(comment, contract)
)
const nonFreeResponseBets =
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
@ -567,12 +577,15 @@ export function getSpecificContractActivityItems(
type: 'commentInput',
id: 'commentInput',
contract,
betsByCurrentUser: user
? nonFreeResponseBets.filter((bet) => bet.userId === user.id)
: [],
comments: nonFreeResponseComments,
betsByCurrentUser: nonFreeResponseBets.filter(
(bet) => bet.userId === user?.id
),
commentsByCurrentUser: nonFreeResponseComments.filter(
(comment) => comment.userId === user?.id
),
})
break
}
case 'free-response-comment-answer-groups':
items.push(
...getAnswerAndCommentInputGroups(

View File

@ -21,14 +21,11 @@ export function CopyLinkDateTimeComponent(props: {
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
event.preventDefault()
const elementLocation = `https://${ENV_CONFIG.domain}${contractPath(
contract
)}#${elementId}`
let currentLocation = window.location.href.includes('/home')
? `https://${ENV_CONFIG.domain}${contractPath(contract)}#${elementId}`
: window.location.href
if (currentLocation.includes('#')) {
currentLocation = currentLocation.split('#')[0]
}
copyToClipboard(`${currentLocation}#${elementId}`)
copyToClipboard(elementLocation)
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}

View File

@ -17,8 +17,11 @@ import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import { CommentInput, FeedItem } from 'web/components/feed/feed-items'
import { getMostRecentCommentableBet } from 'web/components/feed/feed-comments'
import { FeedItem } from 'web/components/feed/feed-items'
import {
CommentInput,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
@ -28,15 +31,16 @@ export function FeedAnswerCommentGroup(props: {
items: ActivityItem[]
type: string
betsByCurrentUser?: Bet[]
comments?: Comment[]
commentsByCurrentUser?: Comment[]
}) {
const { answer, items, contract, betsByCurrentUser, comments } = props
const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } =
props
const { username, avatarUrl, name, text } = answer
const answerElementId = `answer-${answer.id}`
const user = useUser()
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [],
comments ?? [],
commentsByCurrentUser ?? [],
user,
answer.number + ''
)
@ -44,7 +48,7 @@ export function FeedAnswerCommentGroup(props: {
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = comments
const isFreeResponseContractPage = !!commentsByCurrentUser
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
@ -64,7 +68,7 @@ export function FeedAnswerCommentGroup(props: {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
}
}, [router.asPath])
}, [answerElementId, router.asPath])
return (
<Col className={'flex-1 gap-2'}>
@ -174,7 +178,7 @@ export function FeedAnswerCommentGroup(props: {
<CommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []}
comments={comments ?? []}
commentsByCurrentUser={commentsByCurrentUser ?? []}
answerOutcome={answer.number + ''}
replyToUsername={answer.username}
setRef={setInputRef}

Some files were not shown because too many files have changed in this diff Show More