Update to 1.3.1, merge branch 'main' into link-summoner
This commit is contained in:
commit
7caaa69b6f
55
.github/workflows/check.yml
vendored
Normal file
55
.github/workflows/check.yml
vendored
Normal 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
29
common/.eslintrc.js
Normal 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'],
|
||||
},
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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: '',
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 world’s 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 we’re 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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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+$/
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
5
common/numeric-constants.ts
Normal file
5
common/numeric-constants.ts
Normal 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'
|
|
@ -3,6 +3,7 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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]]))
|
||||
}
|
||||
|
|
|
@ -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
12
common/tsconfig.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2017"
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
|
@ -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(' ')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
25
functions/.eslintrc.js
Normal 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'],
|
||||
},
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
25
functions/src/scripts/backfill-fees.ts
Normal file
25
functions/src/scripts/backfill-fees.ts
Normal 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.`)
|
||||
})
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
import * as fs from 'fs'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -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 ?? []),
|
||||
])
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
42
web/components/bucket-input.tsx
Normal file
42
web/components/bucket-input.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
56
web/components/choices-toggle-group.tsx
Normal file
56
web/components/choices-toggle-group.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'} />
|
||||
|
|
|
@ -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}
|
||||
|
|
99
web/components/contract/numeric-graph.tsx
Normal file
99
web/components/contract/numeric-graph.tsx
Normal 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>
|
||||
)
|
||||
}
|
290
web/components/contract/quick-bet.tsx
Normal file
290
web/components/contract/quick-bet.tsx
Normal 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'
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import React from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user