Compare commits

...

238 Commits

Author SHA1 Message Date
Phil
c762869b83
Migrate prod bot (#1052)
* Updated dev config to point to new Twitch dev bot hosting location.

* Updated prod config to point to new Twitch bot hosting location.
2022-10-14 16:41:06 +01:00
FRC
aaa09f49c0
New component to add arbitrary (but static) react embeds to posts programatically + midterms component (#1051) 2022-10-14 16:21:54 +01:00
FRC
4d214c01b4
Tipping in posts (#1045)
* Tipping in posts

* Rm itemType field
2022-10-14 13:05:07 +01:00
ingawei
0a70652667
fixed tip button bug (#1049)
* fixed tip button bug
2022-10-14 02:20:32 -05:00
mqp
b4162a0896 Auto-prettification 2022-10-14 06:49:23 +00:00
ingawei
2ecece02c3 Auto-remove unused imports 2022-10-14 06:48:40 +00:00
ingawei
4359ad0530
added cancelling tipping and lil coin (#1047)
* added cancelling tipping and lil coin
* forced timeout to fix weird toast bug
2022-10-14 01:47:51 -05:00
ingawei
3f8988bf27
Inga/colorful answer comments (#1040)
* changing answers in comments to match colors, no longer indenting those responses
2022-10-14 00:07:54 -05:00
Austin Chen
41a46aad9b Update stability-client to fix /dream 2022-10-13 21:44:22 -07:00
James Grugett
4097082c75
Dynamically choose outcome in Position tooltip (#1048) 2022-10-13 23:29:57 -05:00
mantikoros
58cd0e57bd track tournaments 2022-10-13 21:22:32 -05:00
Austin Chen
3a63503161 Fix @/% suggestion regression
See https://github.com/ueberdosis/tiptap/pull/3239
2022-10-13 19:06:03 -07:00
mantikoros
abd06f272b contract card: show traders instead of volume 2022-10-13 21:05:21 -05:00
Austin Chen
ab1c3020da Warn users about Dream breakage 2022-10-13 18:45:09 -07:00
Sinclair Chen
c044460a91
Don't break build on unused import (#1046)
* Fix lint warnings

* Let vercel build when unused import
2022-10-13 18:16:22 -07:00
mantikoros
7a6725ee77 contract card: less busy design 2022-10-13 19:56:16 -05:00
Austin Chen
823d1ddd4c Update stability-client 2022-10-13 17:01:05 -07:00
Austin Chen
7d490e0de1 Expand image on hover 2022-10-13 16:23:25 -07:00
Sinclair Chen
96d2255cb1 fix find and replace mistake 2022-10-13 15:15:45 -07:00
Sinclair Chen
4a139c5cc2 Fix limit prob styling 2022-10-13 14:55:48 -07:00
James Grugett
47eb8abed0 Don't include loans in email payout message 2022-10-13 16:05:40 -05:00
Sinclair Chen
903fcc83b3 Fix form-control styling 2022-10-13 13:20:04 -07:00
Austin Chen
0615bb2d4b
List ManifoldDream as a bot 2022-10-13 13:12:22 -07:00
mantikoros
3508c94634 tip button: fix tip color 2022-10-13 14:33:41 -05:00
mantikoros
29c0dfe3fe tip button: remove fill color 2022-10-13 14:27:47 -05:00
mantikoros
18a3b66164 dream label 2022-10-13 14:16:30 -05:00
James Grugett
9e4f41253f Filter out resolved markets from daily movers 2022-10-13 13:49:48 -05:00
Sinclair Chen
546b0231e7
Die Daisy (#1042)
* un-daisy labels

* un-daisy inputs

* un-daisy input groups

* fixup input

* un-daisy selects

* un-daisy slider

* Uninstall daisy. Migrate colors

* un-daisy tables

* fix input error styling

* lint
2022-10-13 11:23:42 -07:00
Phil
8bb44593f3
Updated dev config to point to new Twitch dev bot hosting location. (#1044) 2022-10-13 17:19:50 +01:00
Pico2x
2c2bc61788 Fix bug with parsing in abritrary react components 2022-10-13 16:56:35 +01:00
Pico2x
34c9dbb3e7 Revert "Revert "New implementation of market card embeddings (#1025)""
This reverts commit d6525bae9f.
2022-10-13 16:25:15 +01:00
Ian Philips
d6525bae9f Revert "New implementation of market card embeddings (#1025)"
This reverts commit 3fc53112b9.
2022-10-13 09:19:28 -06:00
Ian Philips
da32a756a8 Need not follow contract for tag notification 2022-10-13 08:41:33 -06:00
Ian Philips
fa476c78dd Handle numeric outcomes in movers 2022-10-13 08:18:16 -06:00
Ian Philips
e7ba7e715f default cursor on open answer 2022-10-13 08:04:01 -06:00
Ian Philips
9bf82c6082 Match colors on portfolio 2022-10-13 08:00:04 -06:00
Ian Philips
3e1876f0dc Add flaggedByUsernames to firestore rules 2022-10-13 07:58:14 -06:00
ingawei
5ba4a9dce7
Inga/tip button (#1043)
* added tip jar
* made market actions/comments and manalink buttons IconButtons
2022-10-13 01:53:26 -05:00
James Grugett
4e5b78f4ee Use in-memory store for home featured section data 2022-10-12 21:15:40 -05:00
James Grugett
bc6fab399e Make text grayer 2022-10-12 20:51:48 -05:00
James Grugett
c2d112e516 Position => shares 2022-10-12 20:51:20 -05:00
ingawei
3cbe8ad8bb
Inga/comment bounty fix (#1041)
* fixed bounty button in comments
2022-10-12 20:34:07 -05:00
ingawei
6226291e02 made comments smaller 2022-10-12 17:53:37 -07:00
Austin Chen
fa4dba4da3 Document the new /comment endpoint 2022-10-12 17:26:00 -07:00
Austin Chen
9eff69be75
Add /createcomment API endpoint (#946)
* /dream api: Upload StableDiffusion image to Firestore

* Minor tweaks

* Set content type on uploaded image

This makes it so the image doesn't auto-download when opened in a new tab

* Allow users to dream directly from within Manifold

* Remove unused import

* Implement a /comment endpoint which supports html and markdown

* Upgrade @tiptap/core to latest

* Update all tiptap deps to beta.199

* Add @tiptap/suggestion

* Import @tiptap/html in the right place

* ... add deps everywhere

So I have no idea how common deps work apparently

* Add tiptap/suggestion too

* Clean up dream

* More cleanups

* Rework /comment endpoint

* Move API to /comment

* Change imports in case that matters

* Add a couple todos

* Dynamically import micromark

* Parallellize gsutil with -m option

* Adding comments via api working, editor.tsx erroring out

* Unused import

* Remove disabled state from useTextEditor

Co-authored-by: Ian Philips <iansphilips@gmail.com>
2022-10-12 17:25:17 -07:00
James Grugett
789bec2a4f Expand replies by default (quick fix for comment links not working) 2022-10-12 17:49:04 -05:00
James Grugett
18042cd4d1 Return listener 2022-10-12 17:39:13 -05:00
mantikoros
04a126707b fix welcome dialog page freezing bug 2022-10-12 17:33:18 -05:00
mantikoros
7a412fdb0d add key props 2022-10-12 17:33:18 -05:00
James Grugett
e2dc4c6b8f Resolve markets again script 2022-10-12 17:30:21 -05:00
mantikoros
204d302d87 portfolio copy: "Open" => "Active" 2022-10-12 16:57:21 -05:00
James Grugett
ae39c1175b
Better resolve market payouts (#1038)
* Check payout preconditions first. Try to pay out market in 1 transaction.

* Format

* toBatch => lodash's chunk
2022-10-12 16:21:37 -05:00
Marshall Polaris
c44f223064
Fix some hydration issues (#1033)
* Extract `useIsClient` hook

* Fix a bunch of hydration bugs relevant to the contract page
2022-10-12 14:07:07 -07:00
mantikoros
aa717a767d backfill subsidyPool 2022-10-12 16:05:15 -05:00
Sinclair Chen
d9f57b7daa
Remove all daisy buttons (#1036)
* Tweak limit order UI and fix button

* Style all follow/unfollow buttons blue

also get rid of highlight-blue button

* remove all other uses of 'btn'

* Style group follow button like user follow

* lint and format
2022-10-12 12:27:17 -07:00
mantikoros
93ceaa52c4 payouts: fix subsidyPool undefined bug 2022-10-12 14:24:54 -05:00
James Grugett
de76557326 Show shares as contract card position. Fix bug on outcome 2022-10-12 13:55:58 -05:00
James Grugett
da1fcb646f Fix infinite re-render on home page 2022-10-12 13:42:54 -05:00
ingawei
1d618ba337
Inga/fr remove double comments (#1019)
incorporating answer comments into general comments section
2022-10-12 13:05:58 -05:00
Pico2x
2cda3a4d4f Don't show spinner if pinned items is undefined or zero 2022-10-12 18:12:32 +01:00
Sinclair Chen
e44fc8ae13
Simplify rich text to string parsing logic (#1022)
* Simplify rich text to string parsing logic

* lint
2022-10-12 10:09:59 -07:00
James Grugett
e6a90e18e4 Add more padding and improve layout of post card 2022-10-12 11:59:29 -05:00
Austin Chen
cee8caa3e8
Generate images from StableDiffusion (#1035)
* Generate images from StableDiffusion

* Update yarn.lock

* Log an error, remove extra comment

* Code cleanup

* Note about the API
2022-10-12 09:53:04 -07:00
Pico2x
b49264ddfa Show featured to all users 2022-10-12 17:44:43 +01:00
Pico2x
12ed569ff6 Update post card subtitle color 2022-10-12 17:33:02 +01:00
Pico2x
00acc262a0 Update post-cards to show profile pic correctly 2022-10-12 17:31:00 +01:00
Pico2x
fd7d4eb5e2 Make post-cards consistent with contract cards 2022-10-12 17:22:58 +01:00
Pico2x
8ae1166c49 Posts now have denormalized creator username and name 2022-10-12 16:42:28 +01:00
FRC
84e2b63c49
Entire homepage now loads simultaneously including featured (#1034) 2022-10-12 16:02:57 +01:00
FRCassarino
f19ef83ac2 Auto-remove unused imports 2022-10-12 14:38:59 +00:00
Pico2x
0c11f3b450 Fix is admin in featured 2022-10-12 15:38:06 +01:00
Pico2x
de9ffa2b52 Fix bug in which Featured section shows for everyone 2022-10-12 15:29:32 +01:00
Ian Philips
decb3213f6 Ignore cancel resolutions for proven correct 2022-10-12 08:05:55 -06:00
FRC
ff6278b147
Featured items to homepage (#1024)
* Featured items to homepage

* Fix nits
2022-10-12 15:04:39 +01:00
FRC
3fc53112b9
New implementation of market card embeddings (#1025)
* Grids of cards now implemented by rendering component instead of iframe

* Sinclair's nit
2022-10-12 13:24:22 +01:00
Marshall Polaris
59cdc9f776
Update FR colors, consolidate non-top answers into "Other" (#1031)
* Update FR colors, consolidate non-top answers into "Other"

* Fix answer panel coloration to not be weird and work on Firefox
2022-10-11 23:59:11 -07:00
ingawei
f587e0256d
standardizing red and green colors (#1032) 2022-10-12 01:58:20 -05:00
ingawei
1c209f68f6
de daisy sell button (#1030)
* de daisy sell button
2022-10-12 01:31:32 -05:00
ingawei
b4e7d88ed8
de daisied cancel limit bet button (#1029) 2022-10-12 01:13:43 -05:00
ingawei
b2cd6bbe03
Inga/de daisy follow button (#1028)
* de daisy follow button
2022-10-12 01:00:52 -05:00
ingawei
a6d5d5ad15
made create a post button not daisy (#1027)
yay no daisy
2022-10-12 00:24:59 -05:00
ingawei
beeca57d4e
getting rid of daisy for limit order button (#1026)
* getting rid of daisy for limit order button, got rid of betChoice in limit order panel
2022-10-12 00:09:45 -05:00
Ian Philips
fb8bd1acfb Handle slugs with and without leading / 2022-10-11 17:22:58 -06:00
Ian Philips
4215821f35 Fix firebase query for market creators 2022-10-11 16:59:47 -06:00
Ian Philips
a71c3d6a4a Fix notification link 2022-10-11 16:59:20 -06:00
James Grugett
cdcce421a8 Include today's bets in daily profit 2022-10-11 17:32:35 -05:00
Ian Philips
8beff6eb1f Format money 2022-10-11 15:36:35 -06:00
Ian Philips
f714918b88 Update functions readme with local dev details 2022-10-11 15:32:53 -06:00
James Grugett
946d74489f Gray scale position and profit 2022-10-11 16:29:15 -05:00
mantikoros
220d0841bd move liquidity to info dialog 2022-10-11 16:22:35 -05:00
James Grugett
9d44190b9a Fix group url nav to correct tabs 2022-10-11 16:04:40 -05:00
Sinclair Chen
3cdd790ae9
Autosave post, market, comment rich text (#1015)
* Fix freezing when typing big docs

* Make rich text fields autosave to localstorage

* Add autosave for comments

* delete vestigial text editor from challenges

* Clear autosave on submit post/market/comment

* lint
2022-10-11 12:52:27 -07:00
mantikoros
6c1ac89cbe typo 2022-10-11 14:28:10 -05:00
mantikoros
0d8a84ef06 re-order tournaments 2022-10-11 14:26:52 -05:00
James Grugett
d528566ffa People's=>Party #2 2022-10-11 13:46:58 -05:00
James Grugett
b0f8369d9c People's=>Party 2022-10-11 13:41:30 -05:00
James Grugett
721c18cf6c Add tournament for CCP 20th Congress 2022-10-11 13:37:40 -05:00
mantikoros
43b06ae6fa tip button: show number of tips 2022-10-11 13:30:30 -05:00
Ian Philips
bfdb5ae595 Cleanup comments 2022-10-11 10:26:37 -06:00
Ian Philips
274f7fa849 Send market closed notifications every 5 days 2022-10-11 10:22:38 -06:00
Ian Philips
d507c4092e Code for removing erroneous badges 2022-10-11 08:51:51 -06:00
Ian Philips
e970a908c6 Switch logic to includes 2022-10-11 08:35:59 -06:00
Pico2x
4fd0e5caad Fix bug in which new users are flagged as unreliable 2022-10-11 13:39:40 +01:00
James Grugett
70b2b14f80
Daily profit 💰 (#1023)
* Daily profit client side

* Filter out those where profit rounds to 0

* Tabs to spaces
2022-10-11 00:32:55 -05:00
mantikoros
0ec15ff2f8
Make liquidity great again (#1020)
* add subsidy

* drizzle liquidity

* update liquidity panel

* remove addliquidity

* update cloud functions index

* remove json endpoints

* imports

* drizzle liquidity: add velocity; dev script; run every minute

* adjust speed

* logging

* liquidity button, dialog

* modal size

* modal

* info table

* pay back excess liquidity

* remove client withdrawal

* house liquidity subsidy

* disable liquidity button if market resolved or closed

* format tip amount
2022-10-10 21:56:16 -05:00
Sinclair Chen
8bb9885aee
Fix @mention 500 error and can't close market bug (#1021)
* Fix @mention 500 error. Refactor text concat exts

* lint
2022-10-10 18:47:02 -07:00
Ian Philips
c46c384d1d Add more bot tags, better creator name scaling 2022-10-10 15:38:27 -06:00
Ian Philips
4f5c93be96 Badge notifications ui changes 2022-10-10 15:01:18 -06:00
James Grugett
f03e5d7af0
Refactor portfolio query (#1018)
* Fetch less data for portfolio query

* Rename var
2022-10-10 15:51:51 -05:00
Sinclair Chen
fb0a09664e delete bannerUrl from user type 2022-10-10 13:51:27 -07:00
Ian Philips
17d0fb7da6 Change badge award notif setting group 2022-10-10 14:41:24 -06:00
Ian Philips
867cdf2496 Only backfill unfilled users' badges 2022-10-10 14:34:06 -06:00
Ian Philips
f26ba1c4a2
Award badges for market creation, betting streaks, proven correct (#891)
* Award badges for market creation, betting streaks, proven correct

* Styling

* Add minimum unique bettors for proven correct

* Add name, refactor

* Add notifications for badge awards

* Correct styling

* Need at least 3 unique bettors for market maker badge

* Lint

* Switch to badges_awarded

* Don't include n/a resolutions in market creator badge

* Add badges by rarities to profile

* Show badges on profile, soon on market page

* Add achievements to new user

* Backfill all users badges
2022-10-10 14:32:29 -06:00
James Grugett
cdc64c6475 Correctly configure env var for firebase functions 2022-10-10 14:55:33 -05:00
James Grugett
5d561acdf8 Fix NaN 2022-10-10 14:23:16 -05:00
James Grugett
84f79ffe7c Remove undefined props 2022-10-10 13:34:02 -05:00
James Grugett
f6fd703005
Store each user's contract bet metrics (#1017)
* Implement most of caching metrics per user per contract

* Small group updates refactor

* Write contract-metrics subcollection

* Fix type error
2022-10-10 13:05:17 -05:00
mantikoros
b8ef272784 withdrawal warning 2022-10-10 13:01:57 -05:00
Ian Philips
a4699b79ed If unlisted during creation, fill in creator id 2022-10-10 10:48:01 -06:00
Ian Philips
66071e16fa Try without timeout seconds on pubsub 2022-10-10 09:53:42 -06:00
Ian Philips
b3136ebcac Update update-metrics timeout sends 2022-10-10 09:48:46 -06:00
Ian Philips
a143a96919 Fix unable to close contract 2022-10-10 09:24:37 -06:00
Ian Philips
dea65a4ba0 Better error handling for notification destinations 2022-10-10 07:41:41 -06:00
Ian Philips
a310963952 update prefs safely 2022-10-10 07:01:44 -06:00
Sinclair Chen
8d06e4b4d2
refactor text input into one component (#1016)
* Add responsive text input component

* Add styled expanding textarea component
2022-10-09 19:37:24 -07:00
James Grugett
dc51e2cf46 Rename updateMetrics to scheduleUpdateMetrics 2022-10-09 19:11:44 -05:00
sipec
4831c25ce0 Auto-prettification 2022-10-09 23:10:02 +00:00
Sinclair Chen
60f2552139 copy: Referrals -> Refer a friend 2022-10-09 16:09:21 -07:00
mantikoros
4b8d381da5 hide comment bounty when market closed or resolved 2022-10-09 17:14:20 -05:00
mantikoros
565177b76f track midterms, date docs 2022-10-09 17:02:34 -05:00
Sinclair Chen
8bd21c6693 hotfix %mention, add load-fail state 2022-10-08 22:52:36 -07:00
James Grugett
310a41d63e Make loan and bet streak links hoverable in notifications 2022-10-08 12:51:58 -05:00
mantikoros
e1636d0f13 update metrics: fix divide by zero, elasticity NaN bug 2022-10-08 12:16:45 -05:00
James Grugett
d00ea65279 Add MattP to winners for AMM liquidity exploit 2022-10-07 17:45:42 -05:00
James Grugett
60bb5379cb Update bounties doc 2022-10-07 17:30:20 -05:00
James Grugett
f3dedfb27a
Call updatemetrics v2 cloud function from scheduled function (#1014)
* Call updatemetrics v2 cloud function from scheduled function

* Set limits on bets and contracts loaded for portfolio page. Show warning if limit is hit

* mqp review: Use console.error if !response.ok
2022-10-07 17:10:12 -05:00
mantikoros
efa2e44937 comment bounty dialog, styling 2022-10-07 16:26:30 -05:00
mantikoros
84bc490ed3 comment sort: move below input, newest as default 2022-10-07 16:26:30 -05:00
James Grugett
443397b7dc
Action to merge main into main2 automatically 2022-10-07 15:13:57 -05:00
James Grugett
b57ff68654 Fix highlight & scroll to comment 2022-10-07 14:40:38 -05:00
Sinclair Chen
f0b35993c9 fix hydration error (button inside button) 2022-10-07 10:56:27 -07:00
James Grugett
8f56ccad22 Set limits on bets and contracts loaded for portfolio page. Show warning if limit is hit 2022-10-07 11:55:47 -05:00
mantikoros
9e289146af flat trade fee of M$0.1 aka bot tax 2022-10-06 23:04:48 -05:00
James Grugett
4285198f09 Merge branch 'main' into main2 2022-10-06 22:19:39 -05:00
James Grugett
f533d9bfcb
Verify balance of limit order "makers" (#1007)
* Fetch balance of users with open limit orders & cancel orders with insufficient balance

* Fix imports

* Fix bugs

* Fix a bug

* Remove redundant cast

* buttons overlaying content fix (#1005)

* buttons overlaying content fix

* stats: round DAU number

* made set width for portfolio/profit fields (#1006)

* tournaments: included resolved markets

* made delete red, moved button for regular posts (#1008)

* Fix localstorage saved user being overwritten on every page load

* Market page: Show no right panel while user loading

* Don't flash sign in button if user is loading

* election map coloring

* market group modal scroll fix (#1009)

* midterms: posititoning, make mobile friendly

* Un-daisy share buttons (#1010)

* Make embed and challenge buttons non-daisyui

* Allow link Buttons. Change tweet, dupe buttons.

* lint

* don't insert extra lines when upload photos

* Map fixes (#1011)

* usa map: fix sizing

* useSetIframeBackbroundColor

* preload contracts

* seo

* remove hook

* turn off sprig on dev

* Render timestamp only on client to prevent error of server not matching client

* Make sized container have default height so graph doesn't jump

* midterms: use null in static props

* Create common card component (#1012)

* Create common card component

* lint

* add key prop to pills

* redirect to /home after login

* create market: use transaction

* card: reduce border size

* Update groupContracts in db trigger

* Default sort to best

* Save comment sort per user rather than per contract

* Refactor Pinned Items into a reusable component

* Revert "create market: use transaction"

This reverts commit e1f24f24a9.

* Mark @v with a (Bot) label

* fix padding on daily movers

* fix type errors

* Wrap sprig init in check for window

* unindex date-docs from search engines

* Auto-prettification

* compute elasticity

* change dpm elasticity

* Fix google lighthouse issues (#1013)

* don't hide free response panel on open resolve

* liquidity sort

* Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'.

* Date doc: Toggle to disable creating a prediction market

* Listen for date doc changes

* Fix merge error

* Don't cancel all a users limit orders if they go negative

Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com>
Co-authored-by: mantikoros <sgrugett@gmail.com>
Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com>
Co-authored-by: mantikoros <95266179+mantikoros@users.noreply.github.com>
Co-authored-by: Ian Philips <iansphilips@gmail.com>
Co-authored-by: Pico2x <pico2x@gmail.com>
Co-authored-by: Austin Chen <akrolsmir@gmail.com>
Co-authored-by: sipec <sipec@users.noreply.github.com>
2022-10-06 22:16:49 -05:00
Austin Chen
71b0c71729 Tag ArbitrageBot with bot badge 2022-10-06 21:52:55 -05:00
mantikoros
25333317b0 Show elasticity; volume tooltip 2022-10-06 21:52:55 -05:00
Austin Chen
42a7d04b4d Tag ArbitrageBot with bot badge 2022-10-06 20:17:34 -04:00
James Grugett
b1d386ca5a Listen for date doc changes 2022-10-06 18:54:22 -05:00
James Grugett
0dc8753a92 Listen for date doc changes 2022-10-06 18:50:53 -05:00
mantikoros
454f2d1417 Merge branch 'main' into main2 2022-10-06 18:48:56 -05:00
James Grugett
d846b9fb30 Date doc: Toggle to disable creating a prediction market 2022-10-06 18:42:56 -05:00
James Grugett
77e0631ea4 Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. 2022-10-06 18:42:56 -05:00
James Grugett
badd67c278 Date doc: Toggle to disable creating a prediction market 2022-10-06 18:36:27 -05:00
mantikoros
80622dc7ee liquidity sort 2022-10-06 18:23:27 -05:00
James Grugett
9d12fa3af0 Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. 2022-10-06 18:03:44 -05:00
Sinclair Chen
d9c8925ea0 don't hide free response panel on open resolve 2022-10-06 15:20:46 -07:00
Sinclair Chen
adb809f973
Fix google lighthouse issues (#1013) 2022-10-06 15:19:37 -07:00
mantikoros
a63405ca7c change dpm elasticity 2022-10-06 16:47:52 -05:00
mantikoros
7ca0fb72fc compute elasticity 2022-10-06 16:36:32 -05:00
sipec
ac37f94cf7 Auto-prettification 2022-10-06 20:50:29 +00:00
Sinclair Chen
bc5af50b0c unindex date-docs from search engines 2022-10-06 13:49:39 -07:00
James Grugett
4162cca3ff Wrap sprig init in check for window 2022-10-06 15:23:51 -05:00
mantikoros
91da39370f fix type errors 2022-10-06 14:54:22 -05:00
Sinclair Chen
2f2c586d5d fix padding on daily movers 2022-10-06 12:01:00 -07:00
Austin Chen
853e3e4896 Mark @v with a (Bot) label 2022-10-06 14:20:35 -04:00
Pico2x
edbd0feb37 Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-06 17:04:32 +01:00
Pico2x
59de979949 Refactor Pinned Items into a reusable component 2022-10-06 17:04:00 +01:00
mantikoros
b8d65acc3f Revert "create market: use transaction"
This reverts commit e1f24f24a9.
2022-10-06 10:54:42 -05:00
Ian Philips
26f04fb04a Save comment sort per user rather than per contract 2022-10-06 10:16:29 -04:00
Ian Philips
e127f9646a Default sort to best 2022-10-06 09:53:55 -04:00
Ian Philips
25ef17498a Update groupContracts in db trigger 2022-10-06 09:26:35 -04:00
mantikoros
68075db3da card: reduce border size 2022-10-05 22:20:51 -05:00
mantikoros
e1f24f24a9 create market: use transaction 2022-10-05 22:18:19 -05:00
mantikoros
cd8245fbee redirect to /home after login 2022-10-05 21:38:13 -05:00
mantikoros
f1e400765a add key prop to pills 2022-10-05 21:31:34 -05:00
Sinclair Chen
94624c5387
Create common card component (#1012)
* Create common card component

* lint
2022-10-05 18:02:24 -07:00
mantikoros
7ce09ae39d midterms: use null in static props 2022-10-05 19:24:03 -05:00
James Grugett
935bdd12a7 Make sized container have default height so graph doesn't jump 2022-10-05 18:44:30 -05:00
James Grugett
5d7721e041 Render timestamp only on client to prevent error of server not matching client 2022-10-05 18:44:30 -05:00
mantikoros
a149777c0e turn off sprig on dev 2022-10-05 18:36:32 -05:00
mantikoros
81fb2456bd remove hook 2022-10-05 18:29:08 -05:00
mantikoros
f8ec306ee9
Map fixes (#1011)
* usa map: fix sizing

* useSetIframeBackbroundColor

* preload contracts

* seo
2022-10-05 18:20:40 -05:00
Sinclair Chen
a53fb49ec3 don't insert extra lines when upload photos 2022-10-05 16:08:01 -07:00
Sinclair Chen
7863a4232d
Un-daisy share buttons (#1010)
* Make embed and challenge buttons non-daisyui

* Allow link Buttons. Change tweet, dupe buttons.

* lint
2022-10-05 15:51:10 -07:00
mantikoros
a3b841423f midterms: posititoning, make mobile friendly 2022-10-05 17:12:50 -05:00
ingawei
b8911cafe8
market group modal scroll fix (#1009) 2022-10-05 17:07:41 -05:00
mantikoros
60aa294131 election map coloring 2022-10-05 16:58:47 -05:00
James Grugett
0818a94307 Don't flash sign in button if user is loading 2022-10-05 16:56:47 -05:00
James Grugett
a3acd3fa3c Market page: Show no right panel while user loading 2022-10-05 16:52:16 -05:00
James Grugett
1ef1af8234 Fix localstorage saved user being overwritten on every page load 2022-10-05 16:49:28 -05:00
ingawei
189da4a0cf
made delete red, moved button for regular posts (#1008) 2022-10-05 16:21:03 -05:00
mantikoros
10f0bbc63d tournaments: included resolved markets 2022-10-05 15:46:20 -05:00
ingawei
2d56525d65
made set width for portfolio/profit fields (#1006) 2022-10-05 15:40:26 -05:00
mantikoros
f1f8082600 stats: round DAU number 2022-10-05 15:27:20 -05:00
ingawei
ec006f25c4
buttons overlaying content fix (#1005)
* buttons overlaying content fix
2022-10-05 15:19:10 -05:00
James Grugett
b40a114168 Print error from usePagination 2022-10-05 15:13:40 -05:00
Ian Philips
4bbadeb27c Parse images and iframes from tiptap to string descriptions 2022-10-05 14:08:51 -06:00
mantikoros
2596d54831 update tournaments page 2022-10-05 14:46:15 -05:00
Austin Chen
0df5497ffb Fix regression of unable to purchase mana 2022-10-05 15:35:02 -04:00
Ian Philips
27dabc193c Comment out logs 2022-10-05 13:09:40 -06:00
Ian Philips
6ec1b38a21 Add more stringent duplication req and popularity score 2022-10-05 13:07:23 -06:00
James Grugett
f35eb42d7b Make search query params work on page load 2022-10-05 13:27:46 -05:00
James Grugett
18f8ad433d Hide sort and query options when searching 2022-10-05 13:12:30 -05:00
Sinclair Chen
37e8f2ff5a
Make %mentions actually look like mentions (#993)
* Make contract mentions inline text

* Add tooltip for author, close time, volume

* Make pill wider
2022-10-05 11:11:05 -07:00
Ian Philips
328aa1457d Send interesting markets based on groups, follows, similar bettors 2022-10-05 10:41:57 -06:00
Austin Chen
b9ba3e75fa Show top 5k markets in sitemap 2022-10-05 11:49:47 -04:00
Austin Chen
70bfec2742 Improve sitemaps 2022-10-05 11:41:32 -04:00
Pico2x
26281556f7 Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-05 15:54:13 +01:00
Austin Chen
730abf584a Revert "Warn whenever rich text editor has unsaved changes"
This reverts commit 419219c703.
2022-10-05 10:54:04 -04:00
Pico2x
34d09316e0 Yscale now updates when zooming in on chart 2022-10-05 15:46:18 +01:00
Austin Chen
6f41ab8efd Keep unlisted state on duplicate 2022-10-05 10:38:23 -04:00
Pico2x
f1207e87ec Update share-modal.tsx 2022-10-05 15:18:53 +01:00
Pico2x
4e22b8e332 Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-05 15:01:44 +01:00
Pico2x
07de8cc86a Slightly less horrible color palette 2022-10-05 15:01:41 +01:00
akrolsmir
f07a022d63 Auto-remove unused imports 2022-10-05 13:58:54 +00:00
Austin Chen
d42ec42b0e Standardize on CopyLinkButton 2022-10-05 09:55:46 -04:00
Austin Chen
6fa4e17a58 Remove "Add my comment" button for signed out users 2022-10-05 09:35:21 -04:00
Austin Chen
af3a3a3934 Clean up style on Get M$ 2022-10-05 09:30:51 -04:00
Pico2x
9e3477970d Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-05 14:19:38 +01:00
Pico2x
3390c34d0a Mobile tooltip isn't occluded by finger anymore 2022-10-05 14:19:26 +01:00
Austin Chen
419219c703 Warn whenever rich text editor has unsaved changes 2022-10-05 09:16:05 -04:00
Pico2x
8aaca848b2 Updating group name works again. 2022-10-05 14:02:17 +01:00
Pico2x
e4d7d0a232 Mobile graphs no longer show the zoom function. 2022-10-05 13:57:45 +01:00
Pico2x
e9050973e1 Update post-comments.tsx 2022-10-05 11:53:59 +01:00
FRC
83d9a1f3e2
Posts changes (#988)
* Add post subtitle

* Add "Post" badge to post card

* Move post tab to overview tab, refactor components

* Fix styling nits.
2022-10-05 11:37:23 +01:00
Marshall Polaris
49e97ddac1
Small add-on React 18 fixes (#1004)
* Bump React types for main code to 18

* Replace react-beautiful-dnd with maintained fork

* Add a type annotation
2022-10-05 00:23:23 -07:00
Marshall Polaris
a9d5dd7fc8
Memoize FeedBet component (#999) 2022-10-04 23:17:09 -07:00
Marshall Polaris
ddb186dd98
Change Tipper interface, memoize FeedComment (#1000)
* Change `Tipper` interface, memoize `FeedComment`

* Fixup per James feedback

* More fixup per James feedback
2022-10-04 23:16:56 -07:00
Marshall Polaris
d2273087cf
React 17.0.2 -> 18.2.0 (#1003)
* React 17.0.2 -> 18.2.0

* Adjust title tag to only have one text node (no internal spaces)

* react-expanding-textarea 2.3.5 -> 2.3.6
2022-10-04 23:16:39 -07:00
James Grugett
6a0b577aeb Small home refactor 2022-10-05 00:54:38 -05:00
James Grugett
ca6197c7bb Show total loan amount on portfolio 2022-10-05 00:33:05 -05:00
Sinclair Chen
ed6ea011c2 copy: "Dating docs" -> "Dating" 2022-10-04 18:33:26 -07:00
James Grugett
83d33792aa Loan repaid => Loan payment 2022-10-04 20:10:26 -05:00
sipec
583c5b225e Auto-remove unused imports 2022-10-05 00:49:36 +00:00
Sinclair Chen
9f256aa7a8 refactor: require label on buy/confirm buttons 2022-10-04 17:48:24 -07:00
mantikoros
7a271fce29 fix button 2022-10-04 19:35:53 -05:00
James Grugett
d8ef363f06 Add profit amount to sell dialog. 2022-10-04 19:35:29 -05:00
260 changed files with 7858 additions and 4000 deletions

View File

@ -0,0 +1,17 @@
name: Merge main into main2 on every commit
on:
push:
branches:
- 'main'
jobs:
merge-branch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Merge main -> main2
uses: devmasx/merge-branch@master
with:
type: now
target_branch: main2
github_token: ${{ github.token }}

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-imports': 'warn',
},
},
],

View File

@ -1,4 +1,4 @@
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
import { getCpmmLiquidity } from './calculate-cpmm'
import { CPMMContract } from './contract'
import { LiquidityProvision } from './liquidity-provision'
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
contract: CPMMContract,
newLiquidityProvisionId: string
) => {
const { pool, p, totalLiquidity } = contract
const { pool, p, totalLiquidity, subsidyPool } = contract
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
const liquidity =
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
const liquidity = getCpmmLiquidity(pool, p)
const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId,
userId: userId,
contractId: contract.id,
amount,
pool: newPool,
p: newP,
pool,
p,
liquidity,
createdTime: Date.now(),
}
const newTotalLiquidity = (totalLiquidity ?? 0) + amount
const newSubsidyPool = (subsidyPool ?? 0) + amount
return { newLiquidityProvision, newPool, newP, newTotalLiquidity }
return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool }
}

123
common/badge.ts Normal file
View File

@ -0,0 +1,123 @@
import { User } from './user'
export type Badge = {
type: BadgeTypes
createdTime: number
data: { [key: string]: any }
name: 'Proven Correct' | 'Streaker' | 'Market Creator'
}
export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
export type ProvenCorrectBadgeData = {
type: 'PROVEN_CORRECT'
data: {
contractSlug: string
contractCreatorUsername: string
contractTitle: string
commentId: string
betAmount: number
}
}
export type MarketCreatorBadgeData = {
type: 'MARKET_CREATOR'
data: {
totalContractsCreated: number
}
}
export type StreakerBadgeData = {
type: 'STREAKER'
data: {
totalBettingStreak: number
}
}
export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
export type StreakerBadge = Badge & StreakerBadgeData
export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
export const provenCorrectRarityThresholds = [1, 1000, 10000]
const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
const { betAmount } = badge.data
const thresholdArray = provenCorrectRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (betAmount >= thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export const streakerBadgeRarityThresholds = [1, 50, 250]
const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
const { totalBettingStreak } = badge.data
const thresholdArray = streakerBadgeRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (totalBettingStreak == thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
const { totalContractsCreated } = badge.data
const thresholdArray = marketCreatorBadgeRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (totalContractsCreated == thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export type rarities = 'bronze' | 'silver' | 'gold'
const rarityRanks: { [key: number]: rarities } = {
1: 'bronze',
2: 'silver',
3: 'gold',
}
export const calculateBadgeRarity = (badge: Badge) => {
switch (badge.type) {
case 'PROVEN_CORRECT':
return rarityRanks[
calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
]
case 'MARKET_CREATOR':
return rarityRanks[
calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
]
case 'STREAKER':
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
default:
return rarityRanks[0]
}
}
export const getBadgesByRarity = (user: User | null | undefined) => {
const rarities: { [key in rarities]: number } = {
bronze: 0,
silver: 0,
gold: 0,
}
if (!user) return rarities
Object.values(user.achievements).map((value) => {
value.badges.map((badge) => {
rarities[calculateBadgeRarity(badge)] =
(rarities[calculateBadgeRarity(badge)] ?? 0) + 1
})
})
return rarities
}

View File

@ -1,11 +1,10 @@
import { sum, groupBy, mapValues, sumBy } from 'lodash'
import { groupBy, mapValues, sumBy } from 'lodash'
import { LimitBet } from './bet'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet'
import { binarySearch } from './util/algos'
import { addObjects } from './util/object'
export type CpmmState = {
pool: { [outcome: string]: number }
@ -147,7 +146,8 @@ function calculateAmountToBuyShares(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
// Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each.
@ -157,7 +157,8 @@ function calculateAmountToBuyShares(
amount,
state,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
const totalShares = sumBy(takers, (taker) => taker.shares)
@ -169,7 +170,8 @@ export function calculateCpmmSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares')
@ -180,15 +182,17 @@ export function calculateCpmmSale(
state,
shares,
oppositeOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
const { cpmmState, makers, takers, totalFees } = computeFills(
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
oppositeOutcome,
buyAmount,
state,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
// Transform buys of opposite outcome into sells.
@ -211,6 +215,7 @@ export function calculateCpmmSale(
fees: totalFees,
makers,
takers: saleTakers,
ordersToCancel,
}
}
@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
const { cpmmState } = calculateCpmmSale(
state,
shares,
outcome,
unfilledBets,
balanceByUserId
)
return getCpmmProbability(cpmmState.pool, cpmmState.p)
}
@ -254,48 +266,22 @@ export function addCpmmLiquidity(
return { newPool, liquidity, newP }
}
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
const oldLiquidity = getCpmmLiquidity(l.pool, p)
export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
const userAmounts = groupBy(liquidities, (w) => w.userId)
const totalAmount = sumBy(liquidities, (w) => w.amount)
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
const newLiquidity = getCpmmLiquidity(newPool, p)
const liquidity = newLiquidity - oldLiquidity
return liquidity
}
export function getCpmmLiquidityPoolWeights(
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
) {
const calcLiqudity = calculateLiquidityDelta(state.p)
const liquidityShares = liquidities.map(calcLiqudity)
const shareSum = sum(liquidityShares)
const weights = liquidityShares.map((shares, i) => ({
weight: shares / shareSum,
providerId: liquidities[i].userId,
}))
const includedWeights = excludeAntes
? weights.filter((_, i) => !liquidities[i].isAnte)
: weights
const userWeights = groupBy(includedWeights, (w) => w.providerId)
const totalUserWeights = mapValues(userWeights, (userWeight) =>
sumBy(userWeight, (w) => w.weight)
return mapValues(
userAmounts,
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
)
return totalUserWeights
}
export function getUserLiquidityShares(
userId: string,
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
liquidities: LiquidityProvision[]
) {
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
const weights = getCpmmLiquidityPoolWeights(liquidities)
const userWeight = weights[userId] ?? 0
return mapValues(state.pool, (shares) => userWeight * shares)

View File

@ -1,9 +1,17 @@
import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet } from './bet'
import { Contract } from './contract'
import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash'
import { calculatePayout, getContractBetMetrics } from './calculate'
import { Bet, LimitBet } from './bet'
import {
Contract,
CPMMBinaryContract,
CPMMContract,
DPMContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = (
bets: Bet[],
@ -33,13 +41,81 @@ export const computeInvestmentValueCustomProb = (
const betP = outcome === 'YES' ? p : 1 - p
const payout = betP * shares
const value = payout - (bet.loanAmount ?? 0)
const value = betP * shares
if (isNaN(value)) return 0
return value
})
}
export const computeElasticity = (
bets: Bet[],
contract: Contract,
betAmount = 50
) => {
const { mechanism, outcomeType } = contract
return mechanism === 'cpmm-1' &&
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC')
? computeBinaryCpmmElasticity(bets, contract, betAmount)
: computeDpmElasticity(contract, betAmount)
}
export const computeBinaryCpmmElasticity = (
bets: Bet[],
contract: CPMMContract,
betAmount: number
) => {
const limitBets = bets
.filter(
(b) =>
!b.isFilled &&
!b.isSold &&
!b.isRedemption &&
!b.sale &&
!b.isCancelled &&
b.limitProb !== undefined
)
.sort((a, b) => a.createdTime - b.createdTime) as LimitBet[]
const userIds = uniq(limitBets.map((b) => b.userId))
// Assume all limit orders are good.
const userBalances = Object.fromEntries(
userIds.map((id) => [id, Number.MAX_SAFE_INTEGER])
)
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
'YES',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultYes = getCpmmProbability(poolY, pY)
const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo(
'NO',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultNo = getCpmmProbability(poolN, pN)
// handle AMM overflow
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
return safeYes - safeNo
}
export const computeDpmElasticity = (
contract: DPMContract,
betAmount: number
) => {
return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
@ -123,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
}
const calculateProfitForPeriod = (
startTime: number,
descendingPortfolio: PortfolioMetrics[],
startingPortfolio: PortfolioMetrics | undefined,
currentProfit: number
) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) {
return currentProfit
}
@ -145,33 +216,100 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
portfolioHistory: Record<
'current' | 'day' | 'week' | 'month',
PortfolioMetrics | undefined
>,
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
allTime: allTimeProfit,
}
return newProfit
}
export const calculateMetricsByContract = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const unresolvedContracts = Object.keys(betsByContract)
.map((cid) => contractsById[cid])
.filter((c) => c && !c.isResolved)
return unresolvedContracts.map((c) => {
const bets = betsByContract[c.id] ?? []
const current = getContractBetMetrics(c, bets)
let periodMetrics
if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
const periods = ['day', 'week', 'month'] as const
periodMetrics = Object.fromEntries(
periods.map((period) => [
period,
calculatePeriodProfit(c, bets, period),
])
)
}
return removeUndefinedProps({
contractId: c.id,
...current,
from: periodMetrics,
})
})
}
export type ContractMetrics = ReturnType<
typeof calculateMetricsByContract
>[number]
const calculatePeriodProfit = (
contract: CPMMBinaryContract,
bets: Bet[],
period: 'day' | 'week' | 'month'
) => {
const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
const fromTime = Date.now() - days * DAY_MS
const [previousBets, recentBets] = partition(
bets,
(b) => b.createdTime < fromTime
)
const prevProb = contract.prob - contract.probChanges[period]
const prob = contract.resolutionProbability
? contract.resolutionProbability
: contract.prob
const previousBetsValue = computeInvestmentValueCustomProb(
previousBets,
contract,
prevProb
)
const currentBetsValue = computeInvestmentValueCustomProb(
previousBets,
contract,
prob
)
const { profit: recentProfit, invested: recentInvested } =
getContractBetMetrics(contract, recentBets)
const profit = currentBetsValue - previousBetsValue + recentProfit
const invested = previousBetsValue + recentInvested
const profitPercent = invested === 0 ? 0 : 100 * (profit / invested)
return {
profit,
profitPercent,
invested,
prevValue: previousBetsValue,
value: currentBetsValue,
}
}

View File

@ -78,7 +78,8 @@ export function calculateShares(
export function calculateSaleAmount(
contract: Contract,
bet: Bet,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
@ -87,7 +88,8 @@ export function calculateSaleAmount(
contract,
Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO',
unfilledBets
unfilledBets,
balanceByUserId
).saleValue
: calculateDpmSaleAmount(contract, bet)
}
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
contract: Contract,
outcome: string,
shares: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale(
contract,
shares,
outcome as 'YES' | 'NO',
unfilledBets
unfilledBets,
balanceByUserId
)
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
}
@ -174,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) {
})
}
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1'
@ -210,9 +216,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
}
}
const netPayout = payout - loan
const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100
const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
const hasShares = Object.values(totalShares).some(
@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return {
invested,
loan,
payout,
netPayout,
profit,
profitPercent,
totalShares,
@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() {
return {
invested: 0,
loan: 0,
payout: 0,
netPayout: 0,
profit: 0,
profitPercent: 0,
totalShares: {} as { [outcome: string]: number },

View File

@ -595,7 +595,8 @@ In addition to housing impact litigation, we provide free legal aid, education a
photo:
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637',
preview: 'Donate supplies to soldiers in Ukraine',
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
description:
'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')

View File

@ -10,6 +10,7 @@ export type AnyOutcomeType =
| PseudoNumeric
| FreeResponse
| Numeric
export type AnyContractType =
| (CPMM & Binary)
| (CPMM & PseudoNumeric)
@ -49,6 +50,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume: number
volume24Hours: number
volume7Days: number
elasticity: number
collectedFees: Fees
@ -90,7 +92,8 @@ export type CPMM = {
mechanism: 'cpmm-1'
pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$
totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$
subsidyPool: number // current value of subsidy pool in M$
prob: number
probChanges: {
day: number

View File

@ -16,3 +16,5 @@ export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
export const UNIQUE_BETTOR_LIQUIDITY = 20

View File

@ -16,7 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets',
sprigEnvironmentId: 'Tu7kRZPm7daP',
}

View File

@ -70,7 +70,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
},
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
twitchBotEndpoint: 'https://twitch-bot.manifold.markets',
cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc',
adminEmails: [

View File

@ -1,3 +1,5 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0

3
common/globalConfig.ts Normal file
View File

@ -0,0 +1,3 @@
export type GlobalConfig = {
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}

View File

@ -39,3 +39,4 @@ export type GroupLink = {
createdTime: number
userId?: string
}
export type GroupContractDoc = { contractId: string; createdTime: number }

View File

@ -1,8 +1,9 @@
export type Like = {
id: string // will be id of the object liked, i.e. contract.id
userId: string
type: 'contract'
type: 'contract' | 'post'
createdTime: number
tipTxnId?: string // only holds most recent tip txn id
}
export const LIKE_TIP_AMOUNT = 10
export const TIP_UNDO_DURATION = 2000

View File

@ -17,8 +17,7 @@ import {
import {
CPMMBinaryContract,
DPMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
DPMContract,
NumericContract,
PseudoNumericContract,
} from './contract'
@ -144,7 +143,8 @@ export const computeFills = (
betAmount: number,
state: CpmmState,
limitProb: number | undefined,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}')
@ -166,10 +166,12 @@ export const computeFills = (
shares: number
timestamp: number
}[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0
while (true) {
@ -186,9 +188,20 @@ export const computeFills = (
takers.push(taker)
} else {
// Matched against bet.
i++
const { userId } = maker.bet
const makerBalance = currentBalanceByUserId[userId]
if (floatingGreaterEqual(makerBalance, maker.amount)) {
currentBalanceByUserId[userId] = makerBalance - maker.amount
} else {
// Insufficient balance. Cancel maker bet.
ordersToCancel.push(maker.bet)
continue
}
takers.push(taker)
makers.push(maker)
i++
}
amount -= taker.amount
@ -196,7 +209,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break
}
return { takers, makers, totalFees, cpmmState }
return { takers, makers, totalFees, cpmmState, ordersToCancel }
}
export const getBinaryCpmmBetInfo = (
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills(
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
outcome,
betAmount,
{ pool, p },
limitProb,
unfilledBets
unfilledBets,
balanceByUserId
)
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p,
newTotalLiquidity,
makers,
ordersToCancel,
}
}
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
const { newBet } = getBinaryCpmmBetInfo(
outcome,
betAmount ?? 0,
contract,
limitProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) /
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract | MultipleChoiceContract
contract: DPMContract
) => {
const { pool, totalShares, totalBets } = contract

View File

@ -63,6 +63,7 @@ export function getNewContract(
tags: [],
lowercaseTags: [],
visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false,
createdTime: Date.now(),
closeTime,
@ -70,6 +71,7 @@ export function getNewContract(
volume: 0,
volume24Hours: 0,
volume7Days: 0,
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
collectedFees: {
creatorFee: 0,
@ -110,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
mechanism: 'cpmm-1',
outcomeType: 'BINARY',
totalLiquidity: ante,
subsidyPool: 0,
initialProbability: p,
p,
pool: pool,

View File

@ -4,7 +4,7 @@ export type Notification = {
id: string
userId: string
reasonText?: string
reason?: notification_reason_types
reason?: notification_reason_types | notification_preference
createdTime: number
viewTime?: number
isSeen: boolean
@ -46,6 +46,7 @@ export type notification_source_types =
| 'loan'
| 'like'
| 'tip_and_like'
| 'badge'
export type notification_source_update_types =
| 'created'
@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`,
},
badges_awarded: {
simple: 'New badges awarded',
detailed: 'New badges you have earned',
},
opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:

View File

@ -8,11 +8,13 @@
},
"sideEffects": false,
"dependencies": {
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.191",
"@tiptap/core": "2.0.0-beta.199",
"@tiptap/extension-image": "2.0.0-beta.199",
"@tiptap/extension-link": "2.0.0-beta.199",
"@tiptap/extension-mention": "2.0.0-beta.199",
"@tiptap/html": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.199",
"@tiptap/suggestion": "2.0.0-beta.199",
"lodash": "4.17.21"
},
"devDependencies": {

View File

@ -1,4 +1,3 @@
import { Bet } from './bet'
import { getProbability } from './calculate'
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
@ -56,10 +55,11 @@ export const getLiquidityPoolPayouts = (
outcome: string,
liquidities: LiquidityProvision[]
) => {
const { pool } = contract
const finalPool = pool[outcome]
const { pool, subsidyPool } = contract
const finalPool = pool[outcome] + (subsidyPool ?? 0)
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
const weights = getCpmmLiquidityPoolWeights(liquidities)
return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId,
@ -95,10 +95,11 @@ export const getLiquidityPoolProbPayouts = (
p: number,
liquidities: LiquidityProvision[]
) => {
const { pool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO
const { pool, subsidyPool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO + (subsidyPool ?? 0)
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
const weights = getCpmmLiquidityPoolWeights(liquidities)
return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId,

View File

@ -3,10 +3,19 @@ import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
subtitle: string
content: JSONContent
creatorId: string // User id
createdTime: number
slug: string
// denormalized user fields
creatorName: string
creatorUsername: string
creatorAvatarUrl?: string
likedByUserIds?: string[]
likedByUserCount?: number
}
export type DateDoc = Post & {
@ -17,3 +26,4 @@ export type DateDoc = Post & {
}
export const MAX_POST_TITLE_LENGTH = 480
export const MAX_POST_SUBTITLE_LENGTH = 480

View File

@ -1,8 +1,9 @@
import { groupBy, sumBy, mapValues } from 'lodash'
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import { getContractBetMetrics, resolvedPayout } from './calculate'
import { Contract } from './contract'
import { ContractComment } from './comment'
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
}
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const betsByUser = groupBy(bets, bet => bet.userId)
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
const betsByUser = groupBy(bets, (bet) => bet.userId)
return mapValues(
betsByUser,
(bets) => getContractBetMetrics(contract, bets).profit
)
}
export function addUserScores(
@ -43,3 +47,47 @@ export function addUserScores(
dest[userId] += score
}
}
export function scoreCommentorsAndBettors(
contract: Contract,
bets: Bet[],
comments: ContractComment[]
) {
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = betsById[topBetId]?.userName
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
const topCommentBetId = commentsById[topCommentId]?.betId
return {
topCommentId,
topBetId,
topBettor,
profitById,
commentsById,
betsById,
topCommentBetId,
}
}

View File

@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO',
contract: CPMMContract,
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number
) => {
const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
contract,
shares,
outcome,
unfilledBets
unfilledBets,
balanceByUserId,
)
const probBefore = getCpmmProbability(pool, p)
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
fees,
makers,
takers,
ordersToCancel
}
}

View File

@ -53,7 +53,7 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
badges_awarded: notification_destination_types[]
opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
}
@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = (
onboarding_flow: constructPref(false, false),
opt_out_all: [],
badges_awarded: constructPref(true, false),
}
return defaults
}
@ -178,31 +179,44 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference
) => {
const notificationSettings = privateUser.notificationPreferences
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
try {
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
} catch (e) {
// Fail safely
console.log(
`couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
)
return {
sendToEmail: false,
sendToBrowser: false,
unsubscribeUrl: '',
urlToManageThisNotification: '',
}
}
}

View File

@ -1,5 +1,6 @@
import { notification_preferences } from './user-notification-preferences'
import { ENV_CONFIG } from 'common/envs/constants'
import { ENV_CONFIG } from './envs/constants'
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
export type User = {
id: string
@ -11,7 +12,6 @@ export type User = {
// For their user page
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
@ -51,6 +51,18 @@ export type User = {
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
isBannedFromPosting?: boolean
achievements: {
provenCorrect?: {
badges: ProvenCorrectBadge[]
}
marketCreator?: {
badges: MarketCreatorBadge[]
}
streaker?: {
badges: StreakerBadge[]
}
}
}
export type PrivateUser = {
@ -81,7 +93,8 @@ export type PortfolioMetrics = {
userId: string
}
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
// TODO: remove. Hardcoding the strings would be better.

24
common/util/color.ts Normal file
View File

@ -0,0 +1,24 @@
export const interpolateColor = (color1: string, color2: string, p: number) => {
const rgb1 = parseInt(color1.replace('#', ''), 16)
const rgb2 = parseInt(color2.replace('#', ''), 16)
const [r1, g1, b1] = toArray(rgb1)
const [r2, g2, b2] = toArray(rgb2)
const q = 1 - p
const rr = Math.round(r1 * q + r2 * p)
const rg = Math.round(g1 * q + g2 * p)
const rb = Math.round(b1 * q + b2 * p)
const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16)
const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}`
return hex
}
function toArray(rgb: number) {
const r = rgb >> 16
const g = (rgb >> 8) % 256
const b = rgb % 256
return [r, g, b]
}

View File

@ -60,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
return `${numStr}${suffix[i] ?? ''}`
}
export function shortFormatNumber(num: number): string {
if (num < 1000) return showPrecision(num, 3)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const i = Math.floor(Math.log10(num) / 3)
const numStr = showPrecision(num / Math.pow(10, 3 * i), 2)
return `${numStr}${suffix[i] ?? ''}`
}
export function toCamelCase(words: string) {
const camelCase = words
.split(' ')

View File

@ -1,4 +1,5 @@
import { generateText, JSONContent } from '@tiptap/core'
import { generateText, JSONContent, Node } from '@tiptap/core'
import { generateJSON } from '@tiptap/html'
// Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
@ -23,7 +24,7 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs'
import { cloneDeep, uniq } from 'lodash'
import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
@ -51,8 +52,28 @@ export function parseMentions(data: JSONContent): string[] {
return uniq(mentions)
}
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
export const exhibitExts = [
// TODO: this is a hack to get around the fact that tiptap doesn't have a
// way to add a node view without bundling in tsx
function skippableComponent(name: string): Node<any, any> {
return Node.create({
name,
group: 'block',
content: 'inline*',
parseHTML() {
return [
{
tag: 'grid-cards-component',
},
]
},
})
}
const stringParseExts = [
// StarterKit extensions
Blockquote,
Bold,
BulletList,
@ -69,28 +90,26 @@ export const exhibitExts = [
Paragraph,
Strike,
Text,
Image,
// other extensions
Link,
Mention,
Iframe,
TiptapTweet,
TiptapSpoiler,
Image.extend({ renderText: () => '[image]' }),
Mention, // user @mention
Mention.extend({ name: 'contract-mention' }), // market %mention
Iframe.extend({
renderText: ({ node }) =>
'[embed]' + node.attrs.src ? `(${node.attrs.src})` : '',
}),
skippableComponent('gridCardsComponent'),
skippableComponent('staticReactEmbedComponent'),
TiptapTweet.extend({ renderText: () => '[tweet]' }),
TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }),
]
export function richTextToString(text?: JSONContent) {
if (!text) return ''
// remove spoiler tags.
const newText = cloneDeep(text)
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
}
})
return generateText(newText, exhibitExts)
return generateText(text, stringParseExts)
}
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
data.content?.forEach((d) => dfs(d, f))
f(data)
export function htmlToRichText(html: string) {
return generateJSON(html, stringParseExts)
}

View File

@ -680,6 +680,17 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
--data-raw '{"outcome": "YES", "shares": 10}'
```
### `POST /v0/comment`
Creates a comment in the specified market. Only supports top-level comments for now.
Parameters:
- `contractId`: Required. The ID of the market to comment on.
- `content`: The comment to post, formatted as [TipTap json](https://tiptap.dev/guide/output#option-1-json), OR
- `html`: The comment to post, formatted as an HTML string, OR
- `markdown`: The comment to post, formatted as a markdown string.
### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending.

View File

@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties
💥 *Awarded on 2022-10-07*
**[Pepe](https://manifold.markets/Pepe): M$10,000**
**[Jack](https://manifold.markets/jack): M$2,000**
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
**[Yev](https://manifold.markets/Yev): M$2,000**
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
**[Matt](https://manifold.markets/MattP): M$5,000**
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
**[Yev](https://manifold.markets/Yev): M$5,000**
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -23,11 +23,17 @@ service cloud.firestore {
allow read;
}
match /globalConfig/globalConfig {
allow read;
allow update: if isAdmin()
allow create: if isAdmin()
}
match /users/{userId} {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
.hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -44,6 +50,10 @@ service cloud.firestore {
allow read;
}
match /{somePath=**}/contract-metrics/{contractId} {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{
allow read;
}
@ -100,7 +110,7 @@ service cloud.firestore {
match /contracts/{contractId} {
allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid;

3
functions/.env.dev Normal file
View File

@ -0,0 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=DEV

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-imports': 'warn',
},
},
],

View File

@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
3. `$ firebase login` to authenticate the CLI tools to Firebase
4. `$ firebase use dev` to choose the dev project
### For local development
#### (Installing) For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
1. 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.`):
@ -35,10 +35,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Developing locally
0. `$ firebase use dev` if you haven't already
1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
Note: You have to kill and restart emulators when you change code; no hot reload =(
2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown
0. `$ ./dev.sh localdb` to start the local emulator and front end
1. If you change db trigger code, you have to start (doesn't have to complete) the deploy of it to dev to cause a hard emulator code refresh `$ firebase deploy --only functions:dbTriggerNameHere`
- There's surely a better way to cause/react to a db trigger update but just adding this here for now as it works
2. If you want to test a scheduled function replace your function in `test-scheduled-function.ts` and send a GET to `http://localhost:8088/testscheduledfunction` (Best user experience is via [Postman](https://www.postman.com/downloads/)!)
## Firestore Commands

View File

@ -5,7 +5,7 @@
"firestore": "dev-mantic-markets.appspot.com"
},
"scripts": {
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist",
"compile": "tsc -b",
"watch": "tsc -w",
"shell": "yarn build && firebase functions:shell",
@ -15,9 +15,9 @@
"dev": "nodemon src/serve.ts",
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:rename-remote-backup-folder": "gsutil -m mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
@ -26,11 +26,13 @@
"dependencies": {
"@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.191",
"@tiptap/core": "2.0.0-beta.199",
"@tiptap/extension-image": "2.0.0-beta.199",
"@tiptap/extension-link": "2.0.0-beta.199",
"@tiptap/extension-mention": "2.0.0-beta.199",
"@tiptap/html": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.199",
"@tiptap/suggestion": "2.0.0-beta.199",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",
@ -38,6 +40,7 @@
"firebase-functions": "3.21.2",
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"marked": "4.1.1",
"module-alias": "2.2.2",
"node-fetch": "2",
"stripe": "8.194.0",
@ -45,6 +48,7 @@
},
"devDependencies": {
"@types/mailgun-js": "0.22.12",
"@types/marked": "4.0.7",
"@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3",

View File

@ -3,24 +3,18 @@ import { z } from 'zod'
import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
export const addliquidity = newEndpoint({}, async (req, auth) => {
export const addsubsidy = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
.collection(`contracts/${contractId}/liquidity`)
.doc()
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision(
user.id,
amount,
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
return {
status: 'error',
message: 'Liquidity injection rejected due to overflow error.',
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
transaction.update(contractDoc, {
subsidyPool: newSubsidyPool,
totalLiquidity: newTotalLiquidity,
} as Partial<CPMMContract>)
const newBalance = user.balance - amount
const newTotalDeposits = user.totalDeposits - amount
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
})
const firestore = admin.firestore()
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
transaction.update(
firestore.doc(`contracts/${contract.id}`),
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
},
} as EndpointDefinition
}
export const newEndpointNoAuth = (
endpointOpts: EndpointOptions,
fn: (req: Request) => Promise<Output>
) => {
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return {
opts,
handler: async (req: Request, res: Response) => {
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
try {
if (opts.method !== req.method) {
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
}
res.status(200).json(await fn(req))
} catch (e) {
writeResponseError(e, res)
}
},
} as EndpointDefinition
}

View File

@ -0,0 +1,105 @@
import * as admin from 'firebase-admin'
import { getContract, getUser, log } from './utils'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
import { removeUndefinedProps } from '../../common/util/object'
import { htmlToRichText } from '../../common/util/parse'
import { marked } from 'marked'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const postSchema = z.object({
contractId: z.string(),
content: contentSchema.optional(),
html: z.string().optional(),
markdown: z.string().optional(),
})
const MAX_COMMENT_JSON_LENGTH = 20000
// For now, only supports creating a new top-level comment on a contract.
// Replies, posts, chats are not supported yet.
export const createcomment = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { contractId, content, html, markdown } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
const contract = await getContract(contractId)
if (!creator) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
if (!contract) {
throw new APIError(400, 'No contract exists with the given ID.')
}
let contentJson = null
if (content) {
contentJson = content
} else if (html) {
console.log('html', html)
contentJson = htmlToRichText(html)
} else if (markdown) {
const markedParse = marked.parse(markdown)
log('parsed', markedParse)
contentJson = htmlToRichText(markedParse)
log('json', contentJson)
}
if (!contentJson) {
throw new APIError(400, 'No comment content provided.')
}
if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) {
throw new APIError(
400,
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
)
}
const ref = firestore.collection(`contracts/${contractId}/comments`).doc()
const comment = removeUndefinedProps({
id: ref.id,
content: contentJson,
createdTime: Date.now(),
userId: creator.id,
userName: creator.name,
userUsername: creator.username,
userAvatarUrl: creator.avatarUrl,
// OnContract fields
commentType: 'contract',
contractId: contractId,
contractSlug: contract.slug,
contractQuestion: contract.question,
})
await ref.set(comment)
return { status: 'success', comment }
})

View File

@ -6,7 +6,13 @@ import {
Notification,
notification_reason_types,
} from '../../common/notification'
import { User } from '../../common/user'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USER_NAME,
MANIFOLD_USER_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
@ -30,27 +36,26 @@ import {
import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow'
import { Badge } from 'common/badge'
const firestore = admin.firestore()
type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
}
export const createNotification = async (
export const createFollowOrMarketSubsidizedNotification = async (
sourceId: string,
sourceType: 'contract' | 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created',
sourceType: 'liquidity' | 'follow',
sourceUpdateType: 'created',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
miscData?: {
contract?: Contract
recipients?: string[]
slug?: string
title?: string
}
) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const { contract: sourceContract, recipients } = miscData ?? {}
const shouldReceiveNotification = (
userId: string,
@ -94,23 +99,15 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug,
sourceSlug: slug ? slug : sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question,
sourceSlug: sourceContract?.slug,
sourceTitle: sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
}
if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
// TODO: include number and names of bettors waiting for creator to resolve their market
await sendMarketCloseEmail(
reason,
sourceUser,
privateUser,
sourceContract
)
} else if (reason === 'subsidized_your_market') {
if (reason === 'subsidized_your_market') {
// TODO: send email to creator of market that was subsidized
} else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed
@ -127,20 +124,7 @@ export const createNotification = async (
reason: 'on_new_follow',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
} else if (sourceType === 'liquidity' && sourceContract) {
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market',
@ -213,6 +197,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
return await notificationRef.set(removeUndefinedProps(notification))
}
const needNotFollowContractReasons = ['tagged_user']
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
@ -221,7 +206,12 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string,
reason: notification_reason_types
) => {
if (!stillFollowingContract(userId) || sourceUser.id == userId) return
if (
(!stillFollowingContract(userId) &&
!needNotFollowContractReasons.includes(reason)) ||
sourceUser.id == userId
)
return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
@ -1087,6 +1077,81 @@ export const createBountyNotification = async (
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
// maybe TODO: send email notification to comment creator
}
export const createBadgeAwardedNotification = async (
user: User,
badge: Badge
) => {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'badges_awarded'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: user.id,
reason: 'badges_awarded',
createdTime: Date.now(),
isSeen: false,
sourceId: badge.type,
sourceType: 'badge',
sourceUpdateType: 'created',
sourceUserName: MANIFOLD_USER_NAME,
sourceUserUsername: MANIFOLD_USER_USERNAME,
sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
sourceText: `You earned a new ${badge.name} badge!`,
sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
sourceTitle: badge.name,
data: {
badge,
},
}
return await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
}
export const createMarketClosedNotification = async (
contract: Contract,
creator: User,
privateUser: PrivateUser,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${creator.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: creator.id,
reason: 'your_contract_closed',
createdTime: Date.now(),
isSeen: false,
sourceId: contract.id,
sourceType: 'contract',
sourceUpdateType: 'closed',
sourceContractId: contract?.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: contract.closeTime?.toString() ?? new Date().toString(),
sourceContractCreatorUsername: creator.username,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
await sendMarketCloseEmail(
'your_contract_closed',
creator,
privateUser,
contract
)
}

View File

@ -3,7 +3,11 @@ import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import {
Post,
MAX_POST_TITLE_LENGTH,
MAX_POST_SUBTITLE_LENGTH,
} from '../../common/post'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
@ -36,6 +40,7 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
content: contentSchema,
groupId: z.string().optional(),
@ -48,10 +53,8 @@ const postSchema = z.object({
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content, groupId, question, ...otherProps } = validate(
postSchema,
req.body
)
const { title, subtitle, content, groupId, question, ...otherProps } =
validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
@ -68,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => {
if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3
const result = await createMarketHelper(
{
question,
closeTime,
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
contractSlug = result.slug
try {
const result = await createMarketHelper(
{
question,
closeTime,
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
contractSlug = result.slug
} catch (e) {
console.error(e)
}
}
const post: Post = removeUndefinedProps({
@ -89,9 +96,14 @@ export const createpost = newEndpoint({}, async (req, auth) => {
creatorId: creator.id,
slug,
title,
subtitle,
createdTime: Date.now(),
content: content,
contractSlug,
creatorName: creator.name,
creatorUsername: creator.username,
creatorAvatarUrl: creator.avatarUrl,
itemType: 'post',
})
await postRef.create(post)

View File

@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
achievements: {},
}
await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -0,0 +1,69 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../common/contract'
import { batchedWaitAll } from '../../common/util/promise'
import { APIError } from '../../common/api'
import { addCpmmLiquidity } from '../../common/calculate-cpmm'
import { formatMoneyWithDecimals } from '../../common/util/format'
const firestore = admin.firestore()
export const drizzleLiquidity = async () => {
const snap = await firestore
.collection('contracts')
.where('subsidyPool', '>', 1e-7)
.get()
const contractIds = snap.docs.map((doc) => doc.id)
console.log('found', contractIds.length, 'markets to drizzle')
console.log()
await batchedWaitAll(
contractIds.map((cid) => () => drizzleMarket(cid)),
10
)
}
export const drizzleLiquidityScheduler = functions.pubsub
.schedule('* * * * *') // every minute
.onRun(drizzleLiquidity)
const drizzleMarket = async (contractId: string) => {
await firestore.runTransaction(async (trans) => {
const snap = await trans.get(firestore.doc(`contracts/${contractId}`))
const contract = snap.data() as CPMMContract
const { subsidyPool, pool, p, slug, popularityScore } = contract
if ((subsidyPool ?? 0) < 1e-7) return
const r = Math.random()
const logPopularity = Math.log10((popularityScore ?? 0) + 1)
const v = Math.max(1, Math.min(5, logPopularity))
const amount = subsidyPool <= 0.5 ? subsidyPool : r * v * 0.01 * subsidyPool
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
if (!isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
await trans.update(firestore.doc(`contracts/${contract.id}`), {
pool: newPool,
p: newP,
subsidyPool: subsidyPool - amount,
})
console.log(
'added subsidy',
formatMoneyWithDecimals(amount),
'of',
formatMoneyWithDecimals(subsidyPool),
'pool to',
slug
)
console.log()
})
}

View File

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { contractUrl, getUser } from './utils'
import { contractUrl, getUser, log } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
user: User,
privateUser: PrivateUser
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser,
sendTime: string
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
user: User,
privateUser: PrivateUser
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'thank_you_for_purchases'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
'Thanks for your Manifold purchase',
@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser,
contract: Contract
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return
if (!privateUser.email) return
const { username, name, id: userId } = user
const firstName = name.split(' ')[0]
@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async (
const url = `https://${DOMAIN}/${username}/${slug}`
// We ignore if they were able to unsubscribe from market close emails, this is a necessary email
return await sendTemplateEmail(
privateUser.email,
'Your market has closed',
@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async (
{
question,
url,
unsubscribeUrl,
unsubscribeUrl: '',
userId,
name: firstName,
volume: formatMoney(volume),
@ -466,17 +448,13 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[],
deliveryTime?: string
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.trending_markets.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'trending_markets'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
@ -620,18 +598,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'profit_loss_updates'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
const templateData: Record<string, string> = {
@ -656,4 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
: 'portfolio-update',
templateData
)
log('Sent portfolio update email to', privateUser.email)
}

View File

@ -0,0 +1,42 @@
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../../common/contract'
import { isProd } from '../utils'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../../common/antes'
import { getNewLiquidityProvision } from '../../../common/add-liquidity'
const firestore = admin.firestore()
export const addHouseSubsidy = (contractId: string, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contractId}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const contractDoc = firestore.doc(`contracts/${contractId}`)
const snap = await contractDoc.get()
const contract = snap.data() as CPMMContract
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
transaction.update(contractDoc, {
subsidyPool: newSubsidyPool,
totalLiquidity: newTotalLiquidity,
} as Partial<CPMMContract>)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet'
export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './update-metrics'
export { scheduleUpdateMetrics } from './update-metrics'
export * from './update-stats'
export * from './update-loans'
export * from './backup-db'
@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags'
export * from './on-update-contract-follow'
export * from './on-update-like'
export * from './weekly-portfolio-emails'
export * from './drizzle-liquidity'
// v2
export * from './health'
@ -44,8 +45,6 @@ export * from './sell-bet'
export * from './sell-shares'
export * from './claim-manalink'
export * from './create-market'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
@ -53,6 +52,7 @@ export * from './stripe'
export * from './mana-bonus-email'
export * from './close-market'
export * from './update-comment-bounty'
export * from './add-subsidy'
import { health } from './health'
import { transact } from './transact'
@ -65,9 +65,8 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { createcomment } from './create-comment'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market'
@ -77,6 +76,8 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
import { addsubsidy } from './add-subsidy'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -92,10 +93,10 @@ const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
const addSubsidyFunction = toCloudFunction(addsubsidy)
const addCommentBounty = toCloudFunction(addcommentbounty)
const createCommentFunction = toCloudFunction(createcomment)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket)
@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export {
healthFunction as health,
@ -119,8 +121,7 @@ export {
sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket,
addLiquidityFunction as addliquidity,
withdrawLiquidityFunction as withdrawliquidity,
addSubsidyFunction as addsubsidy,
createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket,
@ -131,6 +132,8 @@ export {
acceptChallenge as acceptchallenge,
createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials,
createCommentFunction as createcomment,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
}

View File

@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils'
import { createNotification } from './create-notification'
import { createMarketClosedNotification } from './create-notification'
import { DAY_MS } from '../../common/util/time'
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
export const marketCloseNotifications = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('every 1 hours')
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
const firestore = admin.firestore()
async function sendMarketCloseEmails() {
export async function sendMarketCloseEmails() {
const contracts = await firestore.runTransaction(async (transaction) => {
const snap = await transaction.get(
firestore.collection('contracts').where('isResolved', '!=', true)
)
const contracts = snap.docs.map((doc) => doc.data() as Contract)
const now = Date.now()
const closeContracts = contracts.filter(
(contract) =>
contract.closeTime &&
contract.closeTime < now &&
shouldSendFirstOrFollowUpCloseNotification(contract)
)
return snap.docs
.map((doc) => {
const contract = doc.data() as Contract
if (
contract.resolution ||
(contract.closeEmailsSent ?? 0) >= 1 ||
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
await Promise.all(
closeContracts.map(async (contract) => {
await transaction.update(
firestore.collection('contracts').doc(contract.id),
{
closeEmailsSent: admin.firestore.FieldValue.increment(1),
}
)
return undefined
transaction.update(doc.ref, {
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
})
return contract
})
.filter((x) => !!x) as Contract[]
)
return closeContracts
})
for (const contract of contracts) {
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue
await createNotification(
contract.id,
'contract',
'closed',
await createMarketClosedNotification(
contract,
user,
contract.id + '-closed-at-' + contract.closeTime,
contract.closeTime?.toString() ?? new Date().toString(),
{ contract }
privateUser,
contract.id + '-closed-at-' + contract.closeTime
)
}
}
// The downside of this approach is if this function goes down for the entire
// day of a multiple of the time period after the market has closed, it won't
// keep sending them notifications bc when it comes back online the time period will have passed
function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) {
if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true
const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } =
marketClosedMultipleOfNDaysAgo(contract)
return (
contract.closeEmailsSent > 0 &&
closedMultipleOfNDaysAgo &&
contract.closeEmailsSent === fullTimePeriodsSinceClose
)
}
function marketClosedMultipleOfNDaysAgo(contract: Contract) {
const now = Date.now()
const closeTime = contract.closeTime
if (!closeTime)
return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 }
const daysSinceClose = Math.floor((now - closeTime) / DAY_MS)
return {
closedMultipleOfNDaysAgo:
daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0,
fullTimePeriodsSinceClose: Math.floor(
daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS
),
}
}

View File

@ -12,6 +12,7 @@ import {
revalidateStaticProps,
} from './utils'
import {
createBadgeAwardedNotification,
createBetFillNotification,
createBettingStreakBonusNotification,
createUniqueBettorBonusNotification,
@ -24,6 +25,7 @@ import {
BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT,
UNIQUE_BETTOR_LIQUIDITY,
} from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import { addHouseSubsidy } from './helpers/add-house-subsidy'
import {
StreakerBadge,
streakerBadgeRarityThresholds,
} from '../../common/badge'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -103,7 +110,7 @@ const updateBettingStreak = async (
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await trans.update(userDoc, {
trans.update(userDoc, {
currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime,
})
@ -143,7 +150,7 @@ const updateBettingStreak = async (
log('message:', result.message)
return
}
if (result.txn)
if (result.txn) {
await createBettingStreakBonusNotification(
user,
result.txn.id,
@ -153,6 +160,8 @@ const updateBettingStreak = async (
newBettingStreak,
eventId
)
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await trans.update(contractDoc, {
trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
return { newUniqueBettorIds }
}
)
if (!newUniqueBettorIds) return
if (oldContract.mechanism === 'cpmm-1') {
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
}
const bonusTxnDetails = {
contractId: oldContract.id,
uniqueNewBettorId: bettor.id,
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn }
})
@ -296,3 +314,39 @@ const notifyFills = async (
const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
}
async function handleBettingStreakBadgeAward(
user: User,
newBettingStreak: number
) {
const alreadyHasBadgeForFirstStreak =
user.achievements?.streaker?.badges.some(
(badge) => badge.data.totalBettingStreak === 1
)
// TODO: check if already awarded 50th streak as well
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
if (streakerBadgeRarityThresholds.includes(newBettingStreak)) {
const badge = {
type: 'STREAKER',
name: 'Streaker',
data: {
totalBettingStreak: newBettingStreak,
},
createdTime: Date.now(),
} as StreakerBadge
// update user
await firestore
.collection('users')
.doc(user.id)
.update({
achievements: {
...user.achievements,
streaker: {
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
},
},
})
await createBadgeAwardedNotification(user, badge)
}
}

View File

@ -1,11 +1,20 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNewContractNotification } from './create-notification'
import { getUser, getValues } from './utils'
import {
createBadgeAwardedNotification,
createNewContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market'
import { User } from '../../common/user'
import * as admin from 'firebase-admin'
import {
MarketCreatorBadge,
marketCreatorBadgeRarityThresholds,
} from '../../common/badge'
export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -28,4 +37,43 @@ export const onCreateContract = functions
richTextToString(desc),
mentioned
)
await handleMarketCreatorBadgeAward(contractCreator)
})
const firestore = admin.firestore()
async function handleMarketCreatorBadgeAward(contractCreator: User) {
// get all contracts by user and calculate size of array
const contracts = await getValues<Contract>(
firestore
.collection(`contracts`)
.where('creatorId', '==', contractCreator.id)
.where('resolution', '!=', 'CANCEL')
)
if (marketCreatorBadgeRarityThresholds.includes(contracts.length)) {
const badge = {
type: 'MARKET_CREATOR',
name: 'Market Creator',
data: {
totalContractsCreated: contracts.length,
},
createdTime: Date.now(),
} as MarketCreatorBadge
// update user
await firestore
.collection('users')
.doc(contractCreator.id)
.update({
achievements: {
...contractCreator.achievements,
marketCreator: {
badges: [
...(contractCreator.achievements?.marketCreator?.badges ?? []),
badge,
],
},
},
})
await createBadgeAwardedNotification(contractCreator, badge)
}
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification'
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy'
@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification(
await createFollowOrMarketSubsidizedNotification(
contract.id,
'liquidity',
'created',

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
followerCountCached: FieldValue.increment(1),
})
await createNotification(
await createFollowOrMarketSubsidizedNotification(
followingUser.id,
'follow',
'created',

View File

@ -1,7 +1,19 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { getUser, getValues } from './utils'
import {
createBadgeAwardedNotification,
createCommentOrAnswerOrUpdatedContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import * as admin from 'firebase-admin'
import { ContractComment } from '../../common/comment'
import { scoreCommentorsAndBettors } from '../../common/scoring'
import {
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
ProvenCorrectBadge,
} from '../../common/badge'
import { GroupContractDoc } from '../../common/group'
export const onUpdateContract = functions.firestore
.document('contracts/{contractId}')
@ -9,17 +21,14 @@ export const onUpdateContract = functions.firestore
const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context
const { openCommentBounties, closeTime, question } = contract
const { closeTime, question } = contract
if (
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
if (!previousContract.isResolved && contract.isResolved) {
// No need to notify users of resolution, that's handled in resolve-market
return
}
if (
return await handleResolvedContract(contract)
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
await handleContractGroupUpdated(previousContract, contract)
} else if (
previousContract.closeTime !== closeTime ||
previousContract.question !== question
) {
@ -27,6 +36,64 @@ export const onUpdateContract = functions.firestore
}
})
async function handleResolvedContract(contract: Contract) {
if (
(contract.uniqueBettorCount ?? 0) <
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE ||
contract.resolution === 'CANCEL'
)
return
// get all bets on this contract
const bets = await getValues<Bet>(
firestore.collection(`contracts/${contract.id}/bets`)
)
// get comments on this contract
const comments = await getValues<ContractComment>(
firestore.collection(`contracts/${contract.id}/comments`)
)
const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
scoreCommentorsAndBettors(contract, bets, comments)
if (topCommentBetId && profitById[topCommentBetId] > 0) {
// award proven correct badge to user
const comment = commentsById[topCommentId]
const bet = betsById[topCommentBetId]
const user = await getUser(comment.userId)
if (!user) return
const newProvenCorrectBadge = {
createdTime: Date.now(),
type: 'PROVEN_CORRECT',
name: 'Proven Correct',
data: {
contractSlug: contract.slug,
contractCreatorUsername: contract.creatorUsername,
commentId: comment.id,
betAmount: bet.amount,
contractTitle: contract.question,
},
} as ProvenCorrectBadge
// update user
await firestore
.collection('users')
.doc(user.id)
.update({
achievements: {
...user.achievements,
provenCorrect: {
badges: [
...(user.achievements?.provenCorrect?.badges ?? []),
newProvenCorrectBadge,
],
},
},
})
await createBadgeAwardedNotification(user, newProvenCorrectBadge)
}
}
async function handleUpdatedCloseTime(
previousContract: Contract,
contract: Contract,
@ -51,3 +118,43 @@ async function handleUpdatedCloseTime(
contract
)
}
async function handleContractGroupUpdated(
previousContract: Contract,
contract: Contract
) {
const prevLength = previousContract.groupSlugs?.length ?? 0
const newLength = contract.groupSlugs?.length ?? 0
if (prevLength < newLength) {
// Contract was added to a new group
const groupId = contract.groupLinks?.find(
(link) =>
!previousContract.groupLinks
?.map((l) => l.groupId)
.includes(link.groupId)
)?.groupId
if (!groupId) throw new Error('Could not find new group id')
await firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
.set({
contractId: contract.id,
createdTime: Date.now(),
} as GroupContractDoc)
}
if (prevLength > newLength) {
// Contract was removed from a group
const groupId = previousContract.groupLinks?.find(
(link) =>
!contract.groupLinks?.map((l) => l.groupId).includes(link.groupId)
)?.groupId
if (!groupId) throw new Error('Could not find old group id')
await firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
.delete()
}
}
const firestore = admin.firestore()

View File

@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore()
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId)
}
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
})
async function handleUserUpdatedReferral(user: User, eventId: string) {
@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
)
})
}
async function cancelLimitOrders(userId: string) {
const snapshot = (await firestore
.collectionGroup('bets')
.where('userId', '==', userId)
.where('isFilled', '==', false)
.get()) as QuerySnapshot<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import {
BetInfo,
getBinaryCpmmBetInfo,
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({
contractId: z.string(),
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity,
newP,
makers,
ordersToCancel,
} = await (async (): Promise<
BetInfo & {
makers?: maker[]
ordersToCancel?: LimitBet[]
}
> => {
if (
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100
}
const unfilledBetsSnap = await trans.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { unfilledBets, balanceByUserId } =
await getUnfilledBetsAndUserBalances(trans, contractDoc)
return getBinaryCpmmBetInfo(
outcome,
amount,
contract,
limitProb,
unfilledBets
unfilledBets,
balanceByUserId
)
} else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans)
}
if (ordersToCancel) {
for (const bet of ordersToCancel) {
trans.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
}
const balanceChange =
newBet.amount !== 0
? // quick bet
newBet.amount + FLAT_TRADE_FEE
: // limit order
FLAT_TRADE_FEE
trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) })
log('Updated user balance.')
if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update(
contractDoc,
removeUndefinedProps({
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc
.collection('bets')
.where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet>
}
export const getUnfilledBetsAndUserBalances = async (
trans: Transaction,
contractDoc: DocumentReference
) => {
const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc))
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
// Get balance of all users with open limit orders.
const userIds = uniq(unfilledBets.map((bet) => bet.userId))
const userDocs =
userIds.length === 0
? []
: await trans.getAll(
...userIds.map((userId) => firestore.doc(`users/${userId}`))
)
const users = filterDefined(userDocs.map((doc) => doc.data() as User))
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
return { unfilledBets, balanceByUserId }
}
type maker = {
bet: LimitBet
amount: number

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { mapValues, groupBy, sumBy } from 'lodash'
import { mapValues, groupBy, sumBy, uniqBy } from 'lodash'
import {
Contract,
@ -9,12 +9,20 @@ import {
RESOLUTIONS,
} from '../../common/contract'
import { Bet } from '../../common/bet'
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
import {
getContractPath,
getUser,
getValues,
isProd,
log,
payUsers,
payUsersMultipleTransactions,
revalidateStaticProps,
} from './utils'
import {
getLoanPayouts,
getPayouts,
groupPayoutsByUser,
Payout,
} from '../../common/payouts'
import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object'
@ -28,6 +36,7 @@ import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { User } from 'common/user'
const bodySchema = z.object({
contractId: z.string(),
@ -81,13 +90,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract
const { creatorId } = contract
const firebaseUser = await admin.auth().getUser(auth.uid)
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract,
req.body
)
const resolutionParams = getResolutionParams(contract, req.body)
if (
creatorId !== auth.uid &&
@ -101,6 +107,16 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found')
return await resolveMarket(contract, creator, resolutionParams)
})
export const resolveMarket = async (
contract: Contract,
creator: User,
{ value, resolutions, probabilityInt, outcome }: ResolutionParams
) => {
const { creatorId, closeTime, id: contractId } = contract
const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined
@ -123,15 +139,19 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
(doc) => doc.data() as LiquidityProvision
)
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts(
outcome,
contract,
bets,
liquidities,
resolutions,
resolutionProbability
)
const {
payouts: traderPayouts,
creatorPayout,
liquidityPayouts,
collectedFees,
} = getPayouts(
outcome,
contract,
bets,
liquidities,
resolutions,
resolutionProbability
)
const updatedContract = {
...contract,
@ -145,35 +165,50 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
resolutions,
collectedFees,
}),
subsidyPool: 0,
}
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets)
const payoutsWithoutLoans = [
{ userId: creatorId, payout: creatorPayout, deposit: creatorPayout },
...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })),
...traderPayouts,
]
const payouts = [...payoutsWithoutLoans, ...loanPayouts]
if (!isProd())
console.log(
'payouts:',
payouts,
'trader payouts:',
traderPayouts,
'creator payout:',
creatorPayout,
'liquidity payout:'
'liquidity payout:',
liquidityPayouts,
'loan payouts:',
loanPayouts
)
if (creatorPayout)
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
const userCount = uniqBy(payouts, 'userId').length
const contractDoc = firestore.doc(`contracts/${contractId}`)
await processPayouts(liquidityPayouts, true)
if (userCount <= 499) {
await firestore.runTransaction(async (transaction) => {
payUsers(transaction, payouts)
transaction.update(contractDoc, updatedContract)
})
} else {
await payUsersMultipleTransactions(payouts)
await contractDoc.update(updatedContract)
}
console.log('contract ', contractId, 'resolved to:', outcome)
await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans)
const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId),
@ -200,18 +235,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
)
return updatedContract
})
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts)
const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) =>
payUser(userId, payout, isDeposit)
)
return await Promise.all(payoutPromises)
.catch((e) => ({ status: 'error', message: e }))
.then(() => ({ status: 'success' }))
}
function getResolutionParams(contract: Contract, body: string) {
@ -278,6 +301,8 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
}
type ResolutionParams = ReturnType<typeof getResolutionParams>
function validateAnswer(
contract: FreeResponseContract | MultipleChoiceContract,
answer: number

View File

@ -11,15 +11,18 @@ async function main() {
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
opt_out_all: [],
},
})
if (privateUser.notificationPreferences.badges_awarded === undefined) {
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
badges_awarded: ['browser'],
},
})
}
return
})
)
}

View File

@ -0,0 +1,136 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllUsers, getValues } from '../utils'
import { Contract } from 'common/contract'
import {
MarketCreatorBadge,
marketCreatorBadgeRarityThresholds,
StreakerBadge,
streakerBadgeRarityThresholds,
} from 'common/badge'
import { User } from 'common/user'
initAdmin()
const firestore = admin.firestore()
async function main() {
const users = await getAllUsers()
// const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
// const users = filterDefined([await getUser('uglwf3YKOZNGjjEXKc5HampOFRE2')]) // prod David
// const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
await Promise.all(
users.map(async (user) => {
if (!user.id) return
// Only backfill users without achievements
if (user.achievements === undefined) {
await firestore.collection('users').doc(user.id).update({
achievements: {},
})
user.achievements = {}
user.achievements = await awardMarketCreatorBadges(user)
user.achievements = await awardBettingStreakBadges(user)
console.log('Added achievements to user', user.id)
// going to ignore backfilling the proven correct badges for now
} else {
// Make corrections to existing achievements
await awardMarketCreatorBadges(user)
}
})
)
}
if (require.main === module) main().then(() => process.exit())
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeErrorBadges(user: User) {
if (
user.achievements.streaker?.badges.some(
(b) => b.data.totalBettingStreak > 1
)
) {
console.log(
`User ${
user.id
} has a streaker badge with streaks ${user.achievements.streaker?.badges.map(
(b) => b.data.totalBettingStreak
)}`
)
// delete non 1,50 streaks
user.achievements.streaker.badges =
user.achievements.streaker.badges.filter((b) =>
streakerBadgeRarityThresholds.includes(b.data.totalBettingStreak)
)
// update user
await firestore.collection('users').doc(user.id).update({
achievements: user.achievements,
})
}
}
async function awardMarketCreatorBadges(user: User) {
// Award market maker badges
const contracts = (
await getValues<Contract>(
firestore.collection(`contracts`).where('creatorId', '==', user.id)
)
).filter((c) => !c.resolution || c.resolution != 'CANCEL')
const achievements = {
...user.achievements,
marketCreator: {
badges: [...(user.achievements.marketCreator?.badges ?? [])],
},
}
for (const threshold of marketCreatorBadgeRarityThresholds) {
const alreadyHasBadge = user.achievements.marketCreator?.badges.some(
(b) => b.data.totalContractsCreated === threshold
)
if (alreadyHasBadge) continue
if (contracts.length >= threshold) {
console.log(`User ${user.id} has at least ${threshold} contracts`)
const badge = {
type: 'MARKET_CREATOR',
name: 'Market Creator',
data: {
totalContractsCreated: threshold,
},
createdTime: Date.now(),
} as MarketCreatorBadge
achievements.marketCreator.badges.push(badge)
}
}
// update user
await firestore.collection('users').doc(user.id).update({
achievements,
})
return achievements
}
async function awardBettingStreakBadges(user: User) {
const streak = user.currentBettingStreak ?? 0
const achievements = {
...user.achievements,
streaker: {
badges: [...(user.achievements?.streaker?.badges ?? [])],
},
}
for (const threshold of streakerBadgeRarityThresholds) {
if (streak >= threshold) {
const badge = {
type: 'STREAKER',
name: 'Streaker',
data: {
totalBettingStreak: threshold,
},
createdTime: Date.now(),
} as StreakerBadge
achievements.streaker.badges.push(badge)
}
}
// update user
await firestore.collection('users').doc(user.id).update({
achievements,
})
return achievements
}

View File

@ -0,0 +1,24 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const contractsRef = firestore.collection('contracts')
contractsRef.get().then(async (contractsSnaps) => {
console.log(`Loaded ${contractsSnaps.size} contracts.`)
const needsFilling = contractsSnaps.docs.filter((ct) => {
return !('subsidyPool' in ct.data())
})
console.log(`Found ${needsFilling.length} contracts to update.`)
await Promise.all(
needsFilling.map((ct) => ct.ref.update({ subsidyPool: 0 }))
)
console.log(`Updated all contracts.`)
})
}

View File

@ -3,7 +3,6 @@
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = {
doc: DocumentSnapshot
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
return {
doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} as UpdateSpec
}
}
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {

View File

@ -0,0 +1,8 @@
import { initAdmin } from './script-init'
initAdmin()
import { drizzleLiquidity } from '../drizzle-liquidity'
if (require.main === module) {
drizzleLiquidity().then(() => process.exit())
}

View File

@ -0,0 +1,59 @@
import { initAdmin } from './script-init'
initAdmin()
import { zip } from 'lodash'
import { filterDefined } from 'common/util/array'
import { resolveMarket } from '../resolve-market'
import { getContract, getUser } from '../utils'
if (require.main === module) {
const contractIds = process.argv.slice(2)
if (contractIds.length === 0) {
throw new Error('No contract ids provided')
}
resolveMarketsAgain(contractIds).then(() => process.exit(0))
}
async function resolveMarketsAgain(contractIds: string[]) {
const maybeContracts = await Promise.all(contractIds.map(getContract))
if (maybeContracts.some((c) => !c)) {
throw new Error('Invalid contract id')
}
const contracts = filterDefined(maybeContracts)
const maybeCreators = await Promise.all(
contracts.map((c) => getUser(c.creatorId))
)
if (maybeCreators.some((c) => !c)) {
throw new Error('No creator found')
}
const creators = filterDefined(maybeCreators)
if (
!contracts.every((c) => c.resolution === 'YES' || c.resolution === 'NO')
) {
throw new Error('Only YES or NO resolutions supported')
}
const resolutionParams = contracts.map((c) => ({
outcome: c.resolution as string,
value: undefined,
probabilityInt: undefined,
resolutions: undefined,
}))
const params = zip(contracts, creators, resolutionParams)
for (const [contract, creator, resolutionParams] of params) {
if (contract && creator && resolutionParams) {
console.log('Resolving', contract.question)
try {
await resolveMarket(contract, creator, resolutionParams)
} catch (e) {
console.log(e)
}
}
}
console.log(`Resolved all contracts.`)
}

View File

@ -89,17 +89,20 @@ const getGroups = async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
await Promise.all(
groups.map(async (group) => {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get())
.size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
})
})
}
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
updateTotalContractsAndMembers()
// removeUnusedMemberAndContractFields()
}

View File

@ -1,6 +1,7 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils'
import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
])
const [
[contractSnap, userSnap],
userBetsSnap,
{ unfilledBets, balanceByUserId },
] = await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
getUnfilledBetsAndUserBalances(transaction, contractDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
unfilledBets,
loanPaid
)
const { newBet, newPool, newP, fees, makers, ordersToCancel } =
getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
unfilledBets,
balanceByUserId,
loanPaid
)
if (
!newP ||
@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
})
)
for (const bet of ordersToCancel) {
transaction.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
return { newBet, makers, maxShares, soldShares }
})

View File

@ -19,8 +19,7 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { createcomment } from './create-comment'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
@ -55,16 +54,15 @@ addJsonEndpointRoute('/transact', transact)
addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
addJsonEndpointRoute('/createuser', createuser)
addJsonEndpointRoute('/createanswer', createanswer)
addJsonEndpointRoute('/createcomment', createcomment)
addJsonEndpointRoute('/placebet', placebet)
addJsonEndpointRoute('/cancelbet', cancelbet)
addJsonEndpointRoute('/sellbet', sellbet)
addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api'
import { isProd } from './utils'
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails'
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
// Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here
await sendTrendingMarketsEmailsToAllUsers()
await sendMarketCloseEmails()
return { success: true }
}

View File

@ -1,10 +1,11 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { groupBy, keyBy, sortBy } from 'lodash'
import fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans'
@ -14,18 +15,44 @@ import {
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
calculateMetricsByContract,
computeElasticity,
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise'
import { newEndpointNoAuth } from './api'
import { getFunctionUrl } from '../../common/api'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
export const scheduleUpdateMetrics = functions.pubsub
.schedule('every 15 minutes')
.onRun(async () => {
const url = getFunctionUrl('updatemetrics')
console.log('Scheduling update metrics', url)
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({}),
})
export const updateMetrics = functions
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
const json = await response.json()
if (response.ok) console.log(json)
else console.error(json)
})
export const updatemetrics = newEndpointNoAuth(
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
async (_req) => {
await updateMetricsCore()
return { success: true }
}
)
export async function updateMetricsCore() {
console.log('Loading users')
@ -35,11 +62,7 @@ export async function updateMetricsCore() {
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>(
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
)
const userPortfolioHistory = await loadPortfolioHistory(users)
console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups'))
@ -103,6 +126,7 @@ export async function updateMetricsCore() {
fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
elasticity: computeElasticity(contractBets, contract),
...cpmmFields,
},
}
@ -115,11 +139,10 @@ export async function updateMetricsCore() {
)
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const portfolioHistory = userPortfolioHistory[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? []
const newCreatorVolume = calculateCreatorVolume(userContracts)
const newPortfolio = calculateNewPortfolioMetrics(
@ -127,14 +150,20 @@ export async function updateMetricsCore() {
contractsById,
currentBets
)
const lastPortfolio = last(portfolioHistory)
const currPortfolio = portfolioHistory.current
const didPortfolioChange =
lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
currPortfolio === undefined ||
currPortfolio.balance !== newPortfolio.balance ||
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
currPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const metricsByContract = calculateMetricsByContract(
currentBets,
contractsById
)
const contractRatios = userContracts
.map((contract) => {
if (
@ -144,7 +173,7 @@ export async function updateMetricsCore() {
return 0
}
const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
return contractRatio
})
@ -152,7 +181,7 @@ export async function updateMetricsCore() {
const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
)
let newFractionResolvedCorrectly = 0
let newFractionResolvedCorrectly = 1
if (userContracts.length > 0) {
newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length
@ -165,6 +194,7 @@ export async function updateMetricsCore() {
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
metricsByContract,
}
})
@ -180,63 +210,61 @@ export async function updateMetricsCore() {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}) => {
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
},
subcollectionUpdates: {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: didPortfolioChange ? newPortfolio : {},
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
}
}
)
await writeAsync(
firestore,
userUpdates.map((u) => u.fieldUpdates)
await writeAsync(firestore, userUpdates)
const portfolioHistoryUpdates = filterDefined(
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: newPortfolio,
}
: null
})
)
await writeAsync(
firestore,
userUpdates
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
.map((u) => u.subcollectionUpdates),
'set'
await writeAsync(firestore, portfolioHistoryUpdates, 'set')
const contractMetricsUpdates = userMetrics.flatMap(
({ user, metricsByContract }) => {
const collection = firestore
.collection('users')
.doc(user.id)
.collection('contract-metrics')
return metricsByContract.map((metrics) => ({
doc: collection.doc(metrics.contractId),
fields: metrics,
}))
}
)
await writeAsync(firestore, contractMetricsUpdates, 'set')
log(`Updated metrics for ${users.length} users.`)
try {
const groupUpdates = groups.map((group, index) => {
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
const groupContracts = groupContractIds
.map((e) => contractsById[e.contractId])
.filter((e) => e !== undefined) as Contract[]
const bets = groupContracts.map((e) => {
if (e != null && e.id in betsByContract) {
return betsByContract[e.id] ?? []
} else {
return []
}
})
const groupContracts = filterDefined(
groupContractIds.map((e) => contractsById[e.contractId])
)
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
const creatorScores = scoreCreators(groupContracts)
const traderScores = scoreTraders(groupContracts, bets)
@ -270,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => {
type GroupContractDoc = { contractId: string; createdTime: number }
const BAD_RESOLUTION_THRESHOLD = 0.1
const loadPortfolioHistory = async (users: User[]) => {
const now = Date.now()
const userPortfolioHistory = await batchedWaitAll(
users.map((user) => async () => {
const query = firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.orderBy('timestamp', 'desc')
.limit(1)
const portfolioMetrics = await Promise.all([
getValues<PortfolioMetrics>(query),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 7 * DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 30 * DAY_MS)
),
])
const [current, day, week, month] = portfolioMetrics.map(
(p) => p[0] as PortfolioMetrics | undefined
)
return {
userId: user.id,
current,
day,
week,
month,
}
}),
100
)
return keyBy(userPortfolioHistory, (p) => p.userId)
}

View File

@ -1,7 +1,8 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { FieldValue, Transaction } from 'firebase-admin/firestore'
import { chunk, groupBy, mapValues, sumBy } from 'lodash'
import { chunk } from 'lodash'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group'
@ -47,7 +48,7 @@ export const writeAsync = async (
const batch = db.batch()
for (const { doc, fields } of chunks[i]) {
if (operationType === 'update') {
batch.update(doc, fields)
batch.update(doc, fields as any)
} else {
batch.set(doc, fields)
}
@ -112,6 +113,12 @@ export const getAllPrivateUsers = async () => {
return users.docs.map((doc) => doc.data() as PrivateUser)
}
export const getAllUsers = async () => {
const firestore = admin.firestore()
const users = await firestore.collection('users').get()
return users.docs.map((doc) => doc.data() as User)
}
export const getUserByUsername = async (username: string) => {
const firestore = admin.firestore()
const snap = await firestore
@ -122,38 +129,29 @@ export const getUserByUsername = async (username: string) => {
return snap.empty ? undefined : (snap.docs[0].data() as User)
}
const firestore = admin.firestore()
const updateUserBalance = (
transaction: Transaction,
userId: string,
delta: number,
isDeposit = false
balanceDelta: number,
depositDelta: number
) => {
const firestore = admin.firestore()
return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) return
const user = userSnap.data() as User
const userDoc = firestore.doc(`users/${userId}`)
const newUserBalance = user.balance + delta
// if (newUserBalance < 0)
// throw new Error(
// `User (${userId}) balance cannot be negative: ${newUserBalance}`
// )
if (isDeposit) {
const newTotalDeposits = (user.totalDeposits || 0) + delta
transaction.update(userDoc, { totalDeposits: newTotalDeposits })
}
transaction.update(userDoc, { balance: newUserBalance })
// Note: Balance is allowed to go negative.
transaction.update(userDoc, {
balance: FieldValue.increment(balanceDelta),
totalDeposits: FieldValue.increment(depositDelta),
})
}
export const payUser = (userId: string, payout: number, isDeposit = false) => {
if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout)
return updateUserBalance(userId, payout, isDeposit)
return firestore.runTransaction(async (transaction) => {
updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0)
})
}
export const chargeUser = (
@ -164,7 +162,67 @@ export const chargeUser = (
if (!isFinite(charge) || charge <= 0)
throw new Error('User charge is not positive: ' + charge)
return updateUserBalance(userId, -charge, isAnte)
return payUser(userId, -charge, isAnte)
}
const checkAndMergePayouts = (
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
for (const { payout, deposit } of payouts) {
if (!isFinite(payout)) {
throw new Error('Payout is not finite: ' + payout)
}
if (deposit !== undefined && !isFinite(deposit)) {
throw new Error('Deposit is not finite: ' + deposit)
}
}
const groupedPayouts = groupBy(payouts, 'userId')
return Object.values(
mapValues(groupedPayouts, (payouts, userId) => ({
userId,
payout: sumBy(payouts, 'payout'),
deposit: sumBy(payouts, (p) => p.deposit ?? 0),
}))
)
}
// Max 500 users in one transaction.
export const payUsers = (
transaction: Transaction,
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
const mergedPayouts = checkAndMergePayouts(payouts)
for (const { userId, payout, deposit } of mergedPayouts) {
updateUserBalance(transaction, userId, payout, deposit)
}
}
export const payUsersMultipleTransactions = async (
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
const mergedPayouts = checkAndMergePayouts(payouts)
const payoutChunks = chunk(mergedPayouts, 500)
for (const payoutChunk of payoutChunks) {
await firestore.runTransaction(async (transaction) => {
for (const { userId, payout, deposit } of payoutChunk) {
updateUserBalance(transaction, userId, payout, deposit)
}
})
}
}
export const getContractPath = (contract: Contract) => {

View File

@ -2,12 +2,20 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { getGroup, getPrivateUser, getUser, getValues, log } from './utils'
import {
getAllPrivateUsers,
getGroup,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow'
import { countBy, uniqBy } from 'lodash'
import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions
@ -37,27 +45,27 @@ export async function getTrendingContracts() {
export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6
// const privateUsers =
// isProd()
// ? await getAllPrivateUsers()
// filterDefined([
// await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
const privateUsersToSendEmailsTo =
// get all users that haven't unsubscribed from weekly emails
// isProd()
// ? privateUsers
// .filter((user) => {
// user.notificationPreferences.trending_markets.includes('email') &&
// !user.weeklyTrendingEmailSent
// })
// .slice(125) // Send the emails out in batches
// :
// privateUsers
filterDefined([
await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'),
])
const privateUsers = isProd()
? await getAllPrivateUsers()
: filterDefined([
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
])
const privateUsersToSendEmailsTo = privateUsers
// Get all users that haven't unsubscribed from weekly emails
.filter(
(user) =>
user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent
)
.slice(0, 90) // Send the emails out in batches
// For testing different users on prod: (only send ian an email though)
// const privateUsersToSendEmailsTo = filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
// // isProd()
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
log(
'Sending weekly trending emails to',
@ -75,11 +83,13 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e')
)
.slice(0, 20)
// log(
// `Found ${trendingContracts.length} trending contracts:\n`,
// trendingContracts.map((c) => c.question).join('\n ')
// )
.slice(0, 50)
const uniqueTrendingContracts = removeSimilarQuestions(
trendingContracts,
trendingContracts,
true
).slice(0, 20)
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
@ -87,28 +97,54 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
log(`No email for ${privateUser.username}`)
return
}
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
privateUser.id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
privateUser.id,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets(
privateUser.id,
unBetOnGroupMarkets
)
const marketsAvailableToSend = uniqBy(
[
...(await getUserUnBetOnFollowsMarkets(
privateUser.id,
privateUser.id
)),
...(await getUserUnBetOnGroupsMarkets(privateUser.id)),
...(await getSimilarBettorsMarkets(privateUser.id)),
...chooseRandomSubset(unbetOnFollowedMarkets, 2),
// // Most people will belong to groups but may not follow other users,
// so choose more from the other subsets if the followed markets is sparse
...chooseRandomSubset(
unBetOnGroupMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
...chooseRandomSubset(
similarBettorsMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
],
(contract) => contract.id
)
// at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend)
marketsAvailableToSend.push(
...trendingContracts
.filter(
(contract) =>
!contract.uniqueBettorIds?.includes(privateUser.id) &&
!marketsAvailableToSend.map((c) => c.id).includes(contract.id)
)
.slice(0, numContractsToSend - marketsAvailableToSend.length)
// // at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend) {
const trendingMarketsToSend =
numContractsToSend - marketsAvailableToSend.length
log(
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
)
marketsAvailableToSend.push(
...removeSimilarQuestions(
uniqueTrendingContracts,
marketsAvailableToSend,
false
)
.filter(
(contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
)
.slice(0, trendingMarketsToSend)
)
}
if (marketsAvailableToSend.length < numContractsToSend) {
log(
@ -129,17 +165,12 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
const user = await getUser(privateUser.id)
if (!user) return
console.log(
log(
'sending contracts:',
contractsToSend.map((c) => [c.question, c.popularityScore])
contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
)
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
// await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await sendInterestingMarketsEmail(
user,
privateUsersToSendEmailsTo[0],
contractsToSend
)
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
@ -147,20 +178,12 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
)
}
// TODO: figure out a good minimum popularity score to filter by
const MINIMUM_POPULARITY_SCORE = 2
const MINIMUM_POPULARITY_SCORE = 10
const getUserUnBetOnFollowsMarkets = async (
userId: string,
unBetOnByUserId: string
) => {
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows')
)
console.log(
'follows',
follows.map((f) => f.userId)
)
const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => {
@ -181,29 +204,40 @@ const getUserUnBetOnFollowsMarkets = async (
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(unBetOnByUserId)
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = unBetOnContractsFromFollows
.flat()
const sortedMarkets = uniqBy(
unBetOnContractsFromFollows.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
console.log(
'sorted top 10 follow Markets',
sortedMarkets
.slice(0, 10)
.map((c) => [c.question, c.popularityScore, c.creatorId])
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
return sortedMarkets
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
// log(
// 'top 10 sorted markets by followed users',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
const getUserUnBetOnGroupsMarkets = async (userId: string) => {
const getUserUnBetOnGroupsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
const snap = await firestore
.collectionGroup('groupMembers')
.where('userId', '==', userId)
@ -215,10 +249,8 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
const groups = filterDefined(
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
)
console.log(
'groups',
groups.map((g) => g.name)
)
if (groups.length === 0) return []
const unBetOnContractsFromGroups = await Promise.all(
groups.map(async (group) => {
const unresolvedContracts = await getValues<Contract>(
@ -242,37 +274,53 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
)
})
)
const sortedMarkets = unBetOnContractsFromGroups
.flat()
const sortedMarkets = uniqBy(
unBetOnContractsFromGroups.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
console.log(
'top 10 sorted group Markets',
sortedMarkets
.slice(0, 10)
.map((c) => [c.question, c.popularityScore, c.groupSlugs])
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
return sortedMarkets
const topSortedMarkets = removeSimilarQuestions(
uniqueSortedMarkets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted group markets',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
// Gets markets followed by similar bettors and bet on by similar bettors
const getSimilarBettorsMarkets = async (userId: string) => {
const getSimilarBettorsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
// get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues<Contract>(
firestore
.collection('contracts')
.where('uniqueBettorIds', 'array-contains', userId)
)
if (contractsUserHasBetOn.length === 0) return []
// count the number of times each unique bettor id appears on those contracts
const bettorIdsToCounts = countBy(
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
(bettorId) => bettorId
)
console.log('bettorIdCounts', bettorIdsToCounts)
// sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object.entries(bettorIdsToCounts)
@ -283,57 +331,112 @@ const getSimilarBettorsMarkets = async (userId: string) => {
// get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds.slice(0, 10)
console.log('top sortedBettorIds', similarBettorIds)
if (similarBettorIds.length === 0) return []
// get contracts with unique bettor ids with this user
const contractsSimilarBettorsHaveBetOn = (
await getValues<Contract>(
firestore
.collection('contracts')
.where(
'uniqueBettorIds',
'array-contains-any',
similarBettorIds.slice(0, 10)
)
.orderBy('popularityScore', 'desc')
.limit(100)
)
).filter((contract) => !contract.uniqueBettorIds?.includes(userId))
const contractsSimilarBettorsHaveBetOn = uniqBy(
(
await getValues<Contract>(
firestore
.collection('contracts')
.where(
'uniqueBettorIds',
'array-contains-any',
similarBettorIds.slice(0, 10)
)
.orderBy('popularityScore', 'desc')
.limit(200)
)
).filter(
(contract) =>
!contract.uniqueBettorIds?.includes(userId) &&
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
),
(contract) => contract.id
)
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
const sortedContractsToAppearancesInSimilarBettorsBets =
contractsSimilarBettorsHaveBetOn
.map((contract) => {
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
similarBettorIds.includes(bettorId)
).length
return [contract, appearances] as [Contract, number]
})
.sort((a, b) => b[1] - a[1])
console.log(
'sortedContractsToAppearancesInSimilarBettorsBets',
sortedContractsToAppearancesInSimilarBettorsBets.map((c) => [
c[0].question,
c[1],
])
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
.map((contract) => {
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
similarBettorIds.includes(bettorId)
).length
return [contract, appearances] as [Contract, number]
})
.sort((a, b) => b[1] - a[1])
.map((entry) => entry[0])
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
sortedContractsInSimilarBettorsBets,
sortedContractsInSimilarBettorsBets,
true
)
const topMostSimilarContracts =
sortedContractsToAppearancesInSimilarBettorsBets.map((entry) => entry[0])
const topMostSimilarContracts = removeSimilarQuestions(
uniqueSortedContractsInSimilarBettorsBets,
differentThanTheseContracts,
false
).slice(0, 10)
console.log(
'top 10 sortedContractsToAppearancesInSimilarBettorsBets',
topMostSimilarContracts
.map((c) => [
c.question,
c.uniqueBettorIds?.filter((bid) => similarBettorIds.includes(bid)),
])
.slice(0, 10)
)
// log(
// 'top 10 sorted contracts other similar bettors have bet on',
// topMostSimilarContracts.map((c) => c.question)
// )
return topMostSimilarContracts
}
// search contract array by question and remove contracts with 3 matching words in the question
const removeSimilarQuestions = (
contractsToFilter: Contract[],
byContracts: Contract[],
allowExactSameContracts: boolean
) => {
// log(
// 'contracts to filter by',
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
// )
let contractsToRemove: Contract[] = []
byContracts.length > 0 &&
byContracts.forEach((contract) => {
const contractQuestion = stripNonAlphaChars(
contract.question.toLowerCase()
)
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
(w) => !IGNORE_WORDS.includes(w)
)
contractsToRemove = contractsToRemove.concat(
contractsToFilter.filter(
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
(c2) => {
const significantOverlap =
// TODO: we should probably use a library for comparing strings/sentiments
uniq(
stripNonAlphaChars(c2.question.toLowerCase()).split(' ')
).filter((word) => contractQuestionWords.includes(word)).length >
2
const lessPopular =
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
return (
(significantOverlap && lessPopular) ||
(allowExactSameContracts ? false : c2.id === contract.id)
)
}
)
)
})
// log(
// 'contracts to filter out',
// contractsToRemove.map((c) => c.question)
// )
const returnContracts = contractsToFilter.filter(
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
)
return returnContracts
}
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
const rng = createRNG(seed)
@ -342,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng)
return contracts.slice(0, count)
}
function stripNonAlphaChars(str: string) {
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
}
const IGNORE_WORDS = [
'the',
'a',
'an',
'and',
'or',
'of',
'to',
'in',
'on',
'will',
'be',
'is',
'are',
'for',
'by',
'at',
'from',
'what',
'when',
'which',
'that',
'it',
'as',
'if',
'then',
'than',
'but',
'have',
'has',
'had',
]

View File

@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
)
)
)
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id)
// Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
return await setEmailFlagAsSent(privateUser.id)
const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id)
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
(differences) => Math.abs(differences.profit)
).reverse()
log(
'Found',
investmentValueDifferences.length,
'investment differences for user',
privateUser.id
)
const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
usersToContractsCreated[privateUser.id].length === 0
) {
log(
'No bets in last week, no market movers, no markets created. Not sending an email.'
`No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
return
return await setEmailFlagAsSent(privateUser.id)
}
// Set the flag beforehand just to be safe
await setEmailFlagAsSent(privateUser.id)
await sendWeeklyPortfolioUpdateEmail(
user,
privateUser,
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
performanceData
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
log('Sent weekly portfolio update email to', privateUser.email)
count++
log('sent out emails to users:', count)
})
)
}
async function setEmailFlagAsSent(privateUserId: string) {
await firestore.collection('private-users').doc(privateUserId).update({
weeklyPortfolioUpdateEmailSent: true,
})
}
export type PerContractInvestmentsData = {
questionTitle: string
questionUrl: string

View File

@ -1,121 +0,0 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { subtractObjects } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { noFees } from '../../common/fees'
import { APIError, newEndpoint, validate } from './api'
import { redeemShares } from './redeem-shares'
const bodySchema = z.object({
contractId: z.string(),
})
export const withdrawliquidity = newEndpoint({}, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
return await firestore
.runTransaction(async (trans) => {
const lpDoc = firestore.doc(`users/${auth.uid}`)
const lpSnap = await trans.get(lpDoc)
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
const lp = lpSnap.data() as User
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 CPMMContract
const liquidityCollection = firestore.collection(
`contracts/${contractId}/liquidity`
)
const liquiditiesSnap = await trans.get(liquidityCollection)
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const userShares = getUserLiquidityShares(
auth.uid,
contract,
liquidities,
true
)
// zero all added amounts for now
// can add support for partial withdrawals in the future
liquiditiesSnap.docs
.filter(
(_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid
)
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
const payout = Math.min(...Object.values(userShares))
if (payout <= 0) return {}
const newBalance = lp.balance + payout
const newTotalDeposits = lp.totalDeposits + payout
trans.update(lpDoc, {
balance: newBalance,
totalDeposits: newTotalDeposits,
} as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares)
const minPoolShares = Math.min(...Object.values(newPool))
const adjustedTotal = contract.totalLiquidity - payout
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
trans.update(contractDoc, {
pool: newPool,
totalLiquidity: newTotalLiquidity,
})
const prob = getProbability(contract)
// surplus shares become user's bets
const bets = Object.entries(userShares)
.map(([outcome, shares]) =>
shares - payout < 1 // don't create bet if less than 1 share
? undefined
: ({
userId: auth.uid,
contractId: contract.id,
amount:
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
shares: shares - payout,
outcome,
probBefore: prob,
probAfter: prob,
createdTime: Date.now(),
isLiquidityProvision: true,
fees: noFees,
} as Omit<Bet, 'id'>)
)
.filter((x) => x !== undefined)
for (const bet of bets) {
const doc = firestore.collection(`contracts/${contract.id}/bets`).doc()
trans.create(doc, { id: doc.id, ...bet })
}
return userShares
})
.then(async (result) => {
// redeem surplus bet with pre-existing bets
await redeemShares(auth.uid, contractId)
console.log('userid', auth.uid, 'withdraws', result)
return result
})
})
const firestore = admin.firestore()

View File

@ -24,8 +24,5 @@
"prettier": "2.7.1",
"ts-node": "10.9.1",
"typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
}
}

View File

@ -22,8 +22,9 @@ module.exports = {
'@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-imports': 'warn',
},
ignorePatterns: ['/public/mtg/*'],
env: {
browser: true,
node: true,

3
web/.gitignore vendored
View File

@ -2,4 +2,5 @@
.next
node_modules
out
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
.env*

10
web/components/NoSEO.tsx Normal file
View File

@ -0,0 +1,10 @@
import Head from 'next/head'
/** Exclude page from search results */
export function NoSEO() {
return (
<Head>
<meta name="robots" content="noindex,follow" />
</Head>
)
}

View File

@ -15,7 +15,7 @@ export function SEO(props: {
return (
<Head>
<title>{title} | Manifold Markets</title>
<title>{`${title} | Manifold Markets`}</title>
<meta
property="og:title"

View File

@ -35,7 +35,7 @@ export function AddFundsModal(props: {
<div className="text-xl">{manaToUSD(amountSelected)}</div>
</div>
<div className="modal-action">
<div className="flex">
<Button color="gray-white" onClick={() => setOpen(false)}>
Back
</Button>

View File

@ -6,6 +6,9 @@ import { Col } from './layout/col'
import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
import Slider from 'rc-slider'
import 'rc-slider/assets/index.css'
export function AmountInput(props: {
amount: number | undefined
@ -39,18 +42,13 @@ export function AmountInput(props: {
return (
<>
<Col className={className}>
<Col className={clsx('relative', className)}>
<label className="font-sm md:font-lg relative">
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label}
</span>
<input
className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error',
'w-24 md:w-auto',
inputClassName
)}
<Input
className={clsx('w-24 pl-9 !text-base md:w-auto', inputClassName)}
ref={inputRef}
type="text"
pattern="[0-9]*"
@ -58,13 +56,14 @@ export function AmountInput(props: {
placeholder="0"
maxLength={6}
value={amount ?? ''}
error={!!error}
disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)}
/>
</label>
{error && (
<div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
<div className="absolute -bottom-5 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? (
<>
Not enough funds.
@ -148,7 +147,7 @@ export function BuyAmountInput(props: {
return (
<>
<Row className="gap-4">
<Row className="items-center gap-4">
<AmountInput
amount={amount}
onChange={onAmountChange}
@ -160,14 +159,23 @@ export function BuyAmountInput(props: {
inputRef={inputRef}
/>
{showSlider && (
<input
type="range"
min="0"
max="205"
<Slider
min={0}
max={205}
value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
className="range range-lg only-thumb my-auto align-middle xl:hidden"
step="5"
onChange={(value) => onAmountChange(parseRaw(value as number))}
className="mx-4 !h-4 xl:hidden [&>.rc-slider-rail]:bg-gray-200 [&>.rc-slider-track]:bg-indigo-400 [&>.rc-slider-handle]:bg-indigo-400"
railStyle={{ height: 16, top: 0, left: 0 }}
trackStyle={{ height: 16, top: 0 }}
handleStyle={{
height: 32,
width: 32,
opacity: 1,
border: 'none',
boxShadow: 'none',
top: -2,
}}
step={5}
/>
)}
</Row>

View File

@ -126,7 +126,10 @@ export function AnswerBetPanel(props: {
</div>
{!isModal && (
<button className="btn-ghost btn-circle" onClick={closePanel}>
<button
className="hover:bg-greyscale-2 rounded-full"
onClick={closePanel}
>
<XIcon
className="mx-auto h-8 w-8 text-gray-500"
aria-hidden="true"
@ -192,6 +195,7 @@ export function AnswerBetPanel(props: {
isSubmitting={isSubmitting}
disabled={!!betDisabled}
color={'indigo'}
actionLabel="Buy"
/>
) : (
<BetSignUpPrompt />

View File

@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Linkify } from '../linkify'
import { Input } from '../input'
export function AnswerItem(props: {
answer: Answer
@ -74,8 +75,8 @@ export function AnswerItem(props: {
<Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo &&
(showChoice === 'checkbox' ? (
<input
className="input input-bordered w-24 justify-self-end text-2xl"
<Input
className="w-24 justify-self-end !text-2xl"
type="number"
placeholder={`${roundedProb}`}
maxLength={9}
@ -92,15 +93,15 @@ export function AnswerItem(props: {
<div
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
tradingAllowed(contract) ? 'text-teal-500' : 'text-gray-500'
)}
>
{probPercent}
</div>
))}
{showChoice ? (
<div className="form-control py-1">
<label className="label cursor-pointer gap-3">
<div className="flex flex-col py-1">
<label className="cursor-pointer gap-3 px-1 py-2">
<span className="">Choose this answer</span>
{showChoice === 'radio' && (
<input
@ -143,7 +144,7 @@ export function AnswerItem(props: {
<div
className={clsx(
'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700'
resolution === 'MKT' ? 'text-blue-700' : 'text-teal-600'
)}
>
Chosen{' '}

View File

@ -10,6 +10,7 @@ import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object'
import { BETTOR, PAST_BETS } from 'common/user'
import { Button } from '../button'
export function AnswerResolvePanel(props: {
isAdmin: boolean
@ -109,14 +110,14 @@ export function AnswerResolvePanel(props: {
)}
>
{resolveOption && (
<button
className="btn btn-ghost"
<Button
color="gray-white"
onClick={() => {
setResolveOption(undefined)
}}
>
Clear
</button>
</Button>
)}
<ResolveConfirmationButton

View File

@ -23,14 +23,23 @@ import { Linkify } from 'web/components/linkify'
import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CATEGORY_COLORS } from '../charts/contract/choice'
import { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice'
import { ChatIcon } from '@heroicons/react/outline'
export function getAnswerColor(answer: Answer, answersArray: string[]) {
const colorIndex = answersArray.indexOf(answer.text)
return colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex]
: '#B1B1C7'
}
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
onAnswerCommentClick: (answer: Answer) => void
}) {
const isAdmin = useAdmin()
const { contract } = props
const { contract, onAnswerCommentClick } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract
const [showAllAnswers, setShowAllAnswers] = useState(false)
@ -105,8 +114,8 @@ export function AnswersPanel(props: {
? 'checkbox'
: undefined
const colorSortedAnswer = useChartAnswers(contract).map(
(value, _index) => value.text
const answersArray = useChartAnswers(contract).map(
(answer, _index) => answer.text
)
return (
@ -137,7 +146,8 @@ export function AnswersPanel(props: {
key={item.id}
answer={item}
contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
onAnswerCommentClick={onAnswerCommentClick}
color={getAnswerColor(item, answersArray)}
/>
))}
{hasZeroBetAnswers && !showAllAnswers && (
@ -157,11 +167,9 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div>
)}
{outcomeType === 'FREE_RESPONSE' &&
tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} />
)}
{outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
<CreateAnswerPanel contract={contract} />
)}
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
!resolution && (
@ -184,15 +192,15 @@ export function AnswersPanel(props: {
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
colorIndex: number | undefined
color: string
onAnswerCommentClick: (answer: Answer) => void
}) {
const { answer, contract, colorIndex } = props
const { answer, contract, onAnswerCommentClick, color } = props
const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
const colorWidth = 100 * Math.max(prob, 0.01)
return (
<Col className="my-1 px-2">
@ -208,9 +216,12 @@ function OpenAnswer(props: {
<Col
className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all',
'relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)}
style={{
background: `linear-gradient(to right, ${color}90 ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
}}
>
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row>
@ -219,10 +230,7 @@ function OpenAnswer(props: {
username={username}
avatarUrl={avatarUrl}
/>
<Linkify
className="text-md cursor-pointer whitespace-pre-line"
text={text}
/>
<Linkify className="text-md whitespace-pre-line" text={text} />
</Row>
<Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div>
@ -236,13 +244,16 @@ function OpenAnswer(props: {
BUY
</Button>
)}
{
<button
className="p-1"
onClick={() => onAnswerCommentClick(answer)}
>
<ChatIcon className="text-greyscale-4 hover:text-greyscale-6 h-5 w-5 transition-colors" />
</button>
}
</Row>
</Row>
<hr
color={color}
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
</Col>
</Col>
)

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract'
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash'
import { Button } from '../button'
import { ExpandingInput } from '../expanding-input'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div>
<Textarea
<ExpandingInput
value={text}
onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
className="w-full"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}
@ -197,17 +197,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</>
)}
{user ? (
<button
className={clsx(
'btn mt-2',
canSubmit ? 'btn-outline' : 'btn-disabled',
isSubmitting && 'loading'
)}
<Button
color="green"
size="lg"
loading={isSubmitting}
disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')}
>
Submit
</button>
</Button>
) : (
text && (
<Button

View File

@ -1,8 +1,8 @@
import { MAX_ANSWER_LENGTH } from 'common/answer'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { ExpandingInput } from '../expanding-input'
export function MultipleChoiceAnswers(props: {
answers: string[]
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
{answers.map((answer, i) => (
<Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '}
<Textarea
<ExpandingInput
value={answer}
onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none"
className="ml-2 w-full"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,13 +1,13 @@
import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
import { XCircleIcon } from '@heroicons/react/outline'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'

View File

@ -68,11 +68,11 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser])
useEffect(() => {
if (authUser != null) {
if (authUser) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
} else if (authUser === null) {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])

View File

@ -5,7 +5,6 @@ import { awardCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
import { Contract } from 'common/contract'
import { TextButton } from 'web/components/text-button'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
@ -37,10 +36,17 @@ export function AwardBountyButton(prop: {
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
if (!canUp) return <div />
return (
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}>
<TextButton className={'font-bold'} onClick={submit}>
<Row
className={clsx('my-auto items-center gap-0.5', !canUp ? '-ml-6' : '')}
>
<button
className={
'rounded-full border border-indigo-400 bg-indigo-50 py-0.5 px-2 text-xs text-indigo-400 transition-colors hover:bg-indigo-400 hover:text-white'
}
onClick={submit}
>
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
</TextButton>
</button>
</Row>
)
}

View File

@ -0,0 +1,62 @@
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { getBadgesByRarity } from 'common/badge'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { BadgesModal } from 'web/components/profile/badges-modal'
import { ParsedUrlQuery } from 'querystring'
export const goldClassName = 'text-amber-400'
export const silverClassName = 'text-gray-500'
export const bronzeClassName = 'text-amber-900'
export function BadgeDisplay(props: {
user: User | undefined | null
query: ParsedUrlQuery
}) {
const { user, query } = props
const [showBadgesModal, setShowBadgesModal] = useState(false)
useEffect(() => {
const showBadgesModal = query['show'] == 'badges'
setShowBadgesModal(showBadgesModal)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// get number of badges of each rarity type
const badgesByRarity = getBadgesByRarity(user)
const badgesByRarityItems = Object.entries(badgesByRarity).map(
([rarity, numBadges]) => {
return (
<Row
key={rarity}
className={clsx(
'items-center gap-2',
rarity === 'bronze'
? bronzeClassName
: rarity === 'silver'
? silverClassName
: goldClassName
)}
>
<span className={clsx('-m-0.5 text-lg')}></span>
<span className="text-xs">{numBadges}</span>
</Row>
)
}
)
return (
<Row
className={'cursor-pointer gap-2'}
onClick={() => setShowBadgesModal(true)}
>
{badgesByRarityItems}
{user && (
<BadgesModal
isOpen={showBadgesModal}
setOpen={setShowBadgesModal}
user={user}
/>
)}
</Row>
)
}

View File

@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
user: User
}) {
const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<>
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
contract={contract as CPMMBinaryContract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
mobileView={true}
/>
</Col>

View File

@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
import { Button } from './button'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
@ -34,14 +34,17 @@ export function BetInline(props: {
const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
amount ?? 0,
contract,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
@ -89,10 +92,7 @@ export function BetInline(props: {
/>
<BuyAmountInput
className="-mb-4"
inputClassName={clsx(
'input-sm w-20 !text-base',
error && 'input-error'
)}
inputClassName="w-20 !text-base"
amount={amount}
onChange={setAmount}
error="" // handle error ourselves

View File

@ -25,7 +25,7 @@ import {
NoLabel,
YesLabel,
} from './outcome-label'
import { getProbability } from 'common/calculate'
import { getContractBetMetrics, getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
@ -47,6 +47,7 @@ import { Modal } from './layout/modal'
import { Title } from './title'
import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid'
import { Button } from './button'
export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
@ -55,7 +56,9 @@ export function BetPanel(props: {
const { contract, className } = props
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -86,12 +89,14 @@ export function BetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
</>
) : (
@ -117,7 +122,9 @@ export function SimpleBetPanel(props: {
const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<Col className={className}>
@ -142,6 +149,7 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess}
/>
<LimitOrderPanel
@ -149,6 +157,7 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess}
/>
@ -167,13 +176,21 @@ export function SimpleBetPanel(props: {
export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean
onBuySuccess?: () => void
mobileView?: boolean
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
props
const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
mobileView,
} = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -254,14 +271,15 @@ export function BuyPanel(props: {
})
}
const betDisabled = isSubmitting || !betAmount || error
const betDisabled = isSubmitting || !betAmount || !!error
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
betAmount ?? 0,
contract,
undefined,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const [seeLimit, setSeeLimit] = useState(false)
@ -395,6 +413,7 @@ export function BuyPanel(props: {
disabled={!!betDisabled || outcome === undefined}
size="xl"
color={outcome === 'NO' ? 'red' : 'green'}
actionLabel="Wager"
/>
)}
<button
@ -415,6 +434,7 @@ export function BuyPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
<LimitBets
contract={contract}
@ -430,11 +450,19 @@ export function BuyPanel(props: {
function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
} = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -442,7 +470,6 @@ function LimitOrderPanel(props: {
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
@ -466,7 +493,7 @@ function LimitOrderPanel(props: {
!betAmount ||
rangeError ||
outOfRangeError ||
error ||
!!error ||
(!hasYesLimitBet && !hasNoLimitBet)
const yesLimitProb =
@ -580,7 +607,8 @@ function LimitOrderPanel(props: {
yesAmount,
contract,
yesLimitProb ?? initialProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const yesReturnPercent = formatPercent(yesReturn)
@ -594,7 +622,8 @@ function LimitOrderPanel(props: {
noAmount,
contract,
noLimitProb ?? initialProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const noReturnPercent = formatPercent(noReturn)
@ -602,9 +631,9 @@ function LimitOrderPanel(props: {
return (
<Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4">
<Row className="mt-1 mb-4 items-center gap-4">
<Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500">
<div className="text-sm text-gray-500">
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div>
<ProbabilityOrNumericInput
@ -612,10 +641,11 @@ function LimitOrderPanel(props: {
prob={lowLimitProb}
setProb={setLowLimitProb}
isSubmitting={isSubmitting}
placeholder="10"
/>
</Col>
<Col className="gap-2">
<div className="ml-1 text-sm text-gray-500">
<div className="text-sm text-gray-500">
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div>
<ProbabilityOrNumericInput
@ -623,6 +653,7 @@ function LimitOrderPanel(props: {
prob={highLimitProb}
setProb={setHighLimitProb}
isSubmitting={isSubmitting}
placeholder="90"
/>
</Col>
</Row>
@ -754,22 +785,18 @@ function LimitOrderPanel(props: {
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
{user && (
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: betChoice === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
<Button
size="xl"
disabled={betDisabled}
color={'indigo'}
loading={isSubmitting}
className="flex-1"
onClick={submitBet}
>
{isSubmitting
? 'Submitting...'
: `Submit order${hasTwoBets ? 's' : ''}`}
</button>
</Button>
)}
</Col>
)
@ -829,7 +856,9 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const betDisabled = isSubmitting || !amount || error !== undefined
@ -843,6 +872,9 @@ export function SellPanel(props: {
const saleFrac = soldShares / shares
const loanPaid = saleFrac * loanAmount
const { invested } = getContractBetMetrics(contract, userBets)
const costBasis = invested * saleFrac
async function submitSell() {
if (!user || !amount) return
@ -885,9 +917,11 @@ export function SellPanel(props: {
contract,
sellQuantity ?? 0,
sharesOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const getValue = getMappedValue(contract)
@ -948,20 +982,12 @@ export function SellPanel(props: {
<Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span>
<span className="text-gray-700">{formatMoney(saleValue)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Profit
<span className="text-gray-700">{formatMoney(profit)}</span>
</Row>
{loanPaid !== 0 && (
<>
<Row className="items-center justify-between gap-2 text-gray-500">
Loan repaid
<span className="text-neutral">{formatMoney(-loanPaid)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds
<span className="text-neutral">{formatMoney(netProceeds)}</span>
</Row>
</>
)}
<Row className="items-center justify-between">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
@ -972,20 +998,32 @@ export function SellPanel(props: {
{format(resultProb)}
</div>
</Row>
{loanPaid !== 0 && (
<>
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
Loan payment
<span className="text-gray-700">{formatMoney(-loanPaid)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds
<span className="text-gray-700">{formatMoney(netProceeds)}</span>
</Row>
</>
)}
</Col>
<Spacer h={8} />
<WarningConfirmationButton
marketType="binary"
amount={netProceeds}
amount={undefined}
warning={warning}
isSubmitting={isSubmitting}
onSubmit={betDisabled ? undefined : submitSell}
disabled={!!betDisabled}
size="xl"
color="blue"
actionLabel="Sell"
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
/>
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}

View File

@ -25,10 +25,8 @@ export function BetsSummary(props: {
const isBinary = outcomeType === 'BINARY'
const bets = props.userBets.filter((b) => !b.isAnte)
const { profitPercent, payout, profit, invested } = getContractBetMetrics(
contract,
bets
)
const { profitPercent, payout, profit, invested, hasShares } =
getContractBetMetrics(contract, bets)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
@ -39,6 +37,7 @@ export function BetsSummary(props: {
)
const position = yesWinnings - noWinnings
const outcome = hasShares ? (position > 0 ? 'YES' : 'NO') : undefined
const prob = isBinary ? getProbability(contract) : 0
const expectation = prob * yesWinnings + (1 - prob) * noWinnings
@ -60,7 +59,9 @@ export function BetsSummary(props: {
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Position{' '}
<InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." />
<InfoTooltip
text={`Number of shares you own on net. 1 ${outcome} share = M$1 if the market resolves ${outcome}.`}
/>
</div>
<div className="whitespace-nowrap">
{position > 1e-7 ? (

View File

@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
import {
formatMoney,
@ -17,6 +17,7 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
MAX_USER_BET_CONTRACTS_LOADED,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api'
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
@ -50,6 +51,9 @@ import {
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline'
import { Select } from './select'
import { Table } from './table'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -80,6 +84,10 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const loadedPartialData =
userBets?.length === MAX_USER_BETS_LOADED ||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort',
store: storageStore(safeLocalStorage()),
@ -160,43 +168,53 @@ export function BetsList(props: { user: User }) {
unsettled,
(c) => contractsMetrics[c.id].payout
)
const currentNetInvestment = sumBy(
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col>
<Row className="justify-between gap-4 sm:flex-row">
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentNetInvestment)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
{loadedPartialData && (
<Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4">
<ExclamationIcon className="h-5 w-5" />
<div>Partial trade data only</div>
</Row>
)}
<Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4">
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentBetsValue)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Total loans
</div>
<div className="text-lg">{formatMoney(currentLoan)}</div>
</Col>
</Row>
<Row className="gap-2">
<select
className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
<Select
value={filter}
onChange={(e) => setFilter(e.target.value as BetFilter)}
>
<option value="open">Open</option>
<option value="open">Active</option>
<option value="limit_bet">Limit orders</option>
<option value="sold">Sold</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
</Select>
<select
className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
<Select
value={sort}
onChange={(e) => setSort(e.target.value as BetSort)}
>
@ -204,9 +222,9 @@ export function BetsList(props: { user: User }) {
<option value="value">Value</option>
<option value="profit">Profit</option>
<option value="closeTime">Close date</option>
</select>
</Select>
</Row>
</Row>
</Col>
<Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? (
@ -407,7 +425,9 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<div className="overflow-x-auto">
@ -431,7 +451,7 @@ export function ContractBetsTable(props: {
</>
)}
<table className="table-zebra table-compact table w-full text-gray-500">
<Table>
<thead>
<tr className="p-2">
<th></th>
@ -456,10 +476,11 @@ export function ContractBetsTable(props: {
contract={contract}
isYourBet={isYourBets}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
))}
</tbody>
</table>
</Table>
</div>
)
}
@ -470,8 +491,10 @@ function BetRow(props: {
saleBet?: Bet
isYourBet: boolean
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) {
const { bet, saleBet, contract, isYourBet, unfilledBets } = props
const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
props
const {
amount,
outcome,
@ -499,9 +522,9 @@ function BetRow(props: {
} else if (contract.isResolved) {
return resolvedPayout(contract, bet)
} else {
return calculateSaleAmount(contract, bet, unfilledBets)
return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
}
}, [contract, bet, saleBet, unfilledBets])
}, [contract, bet, saleBet, unfilledBets, balanceByUserId])
const saleDisplay = isAnte ? (
'ANTE'
@ -528,7 +551,7 @@ function BetRow(props: {
return (
<tr>
<td className="text-neutral">
<td className="text-gray-700">
{isYourBet &&
!isCPMM &&
!isResolved &&
@ -540,6 +563,7 @@ function BetRow(props: {
contract={contract}
bet={bet}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
)}
</td>
@ -585,8 +609,9 @@ function SellButton(props: {
contract: Contract
bet: Bet
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) {
const { contract, bet, unfilledBets } = props
const { contract, bet, unfilledBets, balanceByUserId } = props
const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false)
@ -600,10 +625,16 @@ function SellButton(props: {
contract,
outcome,
shares,
unfilledBets
unfilledBets,
balanceByUserId
)
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets)
const saleAmount = calculateSaleAmount(
contract,
bet,
unfilledBets,
balanceByUserId
)
const profit = saleAmount - bet.amount
return (

View File

@ -13,14 +13,48 @@ export type ColorType =
| 'gray-outline'
| 'gradient'
| 'gray-white'
| 'highlight-blue'
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}
export function buttonClass(size: SizeType, color: ColorType | 'override') {
return clsx(
'font-md inline-flex items-center justify-center rounded-md ring-inset shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'ring-2 ring-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 shadow-none disabled:opacity-50'
)
}
export function Button(props: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
color?: ColorType
color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
@ -36,44 +70,10 @@ export function Button(props: {
loading,
} = props
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return (
<button
type={type}
className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses,
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)}
className={clsx(buttonClass(size, color), className)}
disabled={disabled || loading}
onClick={onClick}
>
@ -82,3 +82,39 @@ export function Button(props: {
</button>
)
}
export function IconButton(props: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
}) {
const {
children,
className,
onClick,
size = 'md',
type = 'button',
disabled = false,
loading,
} = props
return (
<button
type={type}
className={clsx(
'inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
'disabled:text-greyscale-2 text-greyscale-5 hover:text-greyscale-6',
className
)}
disabled={disabled || loading}
onClick={onClick}
>
{children}
</button>
)
}

16
web/components/card.tsx Normal file
View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
export function Card(props: JSX.IntrinsicElements['div']) {
const { children, className, ...rest } = props
return (
<div
className={clsx(
'cursor-pointer rounded-lg border bg-white transition-shadow hover:shadow-md focus:shadow-md',
className
)}
{...rest}
>
{children}
</div>
)
}

View File

@ -1,8 +1,7 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { SwitchVerticalIcon } from '@heroicons/react/outline'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
@ -16,16 +15,15 @@ import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = {
amount: number
@ -44,7 +42,6 @@ export function CreateChallengeModal(props: {
const { user, contract, isOpen, setOpen } = props
const [challengeSlug, setChallengeSlug] = useState('')
const [loading, setLoading] = useState(false)
const { editor } = useTextEditor({ placeholder: '' })
return (
<Modal open={isOpen} setOpen={setOpen}>
@ -65,7 +62,6 @@ export function CreateChallengeModal(props: {
question: newChallenge.question,
outcomeType: 'BINARY',
initialProb: 50,
description: editor?.getJSON(),
ante: FIXED_ANTE,
closeTime: dayjs().add(30, 'day').valueOf(),
})
@ -154,9 +150,9 @@ function CreateChallengeForm(props: {
{contract ? (
<span className="underline">{contract.question}</span>
) : (
<Textarea
<ExpandingInput
placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none"
className="mt-1 w-full"
autoFocus={true}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}
@ -175,7 +171,7 @@ function CreateChallengeForm(props: {
<div>You'll bet:</div>
<Row
className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
'w-full max-w-xs items-center justify-between gap-4 pr-3'
}
>
<AmountInput
@ -302,16 +298,7 @@ function CreateChallengeForm(props: {
<Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div>
<button
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<CopyLinkButton url={challengeSlug} />
<QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}>

View File

@ -6,6 +6,8 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
import { Card } from '../card'
export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity } = props
@ -15,43 +17,44 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
const raised = sumBy(txns, (txn) => txn.amount)
return (
<Link href={`/charity/${slug}`} passHref>
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md">
<Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />}
</Row>
<div className="px-8">
<figure className="relative h-32">
{photo ? (
<Image src={photo} alt="" layout="fill" objectFit="contain" />
) : (
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
)}
</figure>
</div>
<div className="card-body">
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
raised
</Row>
{/* {match && (
<Link href={`/charity/${slug}`}>
<a className="flex-1">
<Card className="!rounded-2xl">
<Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />}
</Row>
<div className="px-8">
<figure className="relative h-32">
{photo ? (
<Image src={photo} alt="" layout="fill" objectFit="contain" />
) : (
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
)}
</figure>
</div>
<Col className="p-8">
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
raised
</Row>
{/* {match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)} */}
</Row>
</>
)}
</div>
</div>
</Row>
</>
)}
</Col>
</Card>
</a>
</Link>
)
}

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash'
import { last, range, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
@ -19,86 +19,36 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
export const CATEGORY_COLORS = [
'#00b8dd',
'#eecafe',
'#874c62',
'#6457ca',
'#f773ba',
'#9c6bbc',
'#a87744',
'#af8a04',
'#bff9aa',
'#f3d89d',
'#c9a0f5',
'#ff00e5',
'#9dc6f7',
'#824475',
'#d973cc',
'#bc6808',
'#056e70',
'#677932',
'#00b287',
'#c8ab6c',
'#a2fb7a',
'#f8db68',
'#14675a',
'#8288f4',
'#fe1ca0',
'#ad6aff',
'#786306',
'#9bfbaf',
'#b00cf7',
'#2f7ec5',
'#4b998b',
'#42fa0e',
'#5b80a1',
'#962d9d',
'#3385ff',
'#48c5ab',
'#b2c873',
'#4cf9a4',
'#00ffff',
'#3cca73',
'#99ae17',
'#7af5cf',
'#52af45',
'#fbb80f',
'#29971b',
'#187c9a',
'#00d539',
'#bbfa1a',
'#61f55c',
'#cabc03',
'#ff9000',
'#779100',
'#bcfd6f',
'#70a560',
type ChoiceContract = FreeResponseContract | MultipleChoiceContract
export const CHOICE_ANSWER_COLORS = [
'#97C1EB',
'#F39F83',
'#F9EBA5',
'#FFC7D2',
'#C7ECFF',
'#8CDEC7',
'#DBE96F',
]
export const CHOICE_OTHER_COLOR = '#CCC'
export const CHOICE_ALL_COLORS = [...CHOICE_ANSWER_COLORS, CHOICE_OTHER_COLOR]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = (
contract: FreeResponseContract | MultipleChoiceContract,
topN: number
) => {
const { answers, outcomeType, totalBets } = contract
const validAnswers = answers.filter((answer) => {
return (
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
)
})
const getAnswers = (contract: ChoiceContract) => {
const { answers, outcomeType } = contract
const validAnswers = answers.filter(
(answer) => answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE'
)
return sortBy(
validAnswers,
(answer) => -1 * getOutcomeProbability(contract, answer.id)
).slice(0, topN)
)
}
const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const getBetPoints = (answers: Answer[], bets: Bet[], topN?: number) => {
const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const sharesByOutcome = Object.fromEntries(
@ -112,11 +62,14 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const sharesSquared = sum(
Object.values(sharesByOutcome).map((shares) => shares ** 2)
)
points.push({
x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
obj: bet,
})
const probs = answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared)
if (topN != null && answers.length > topN) {
const y = [...probs.slice(0, topN), sum(probs.slice(topN))]
points.push({ x: new Date(bet.createdTime), y, obj: bet })
} else {
points.push({ x: new Date(bet.createdTime), y: probs, obj: bet })
}
}
return points
}
@ -144,17 +97,12 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
)
}
export function useChartAnswers(
contract: FreeResponseContract | MultipleChoiceContract
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
export function useChartAnswers(contract: ChoiceContract) {
return useMemo(() => getAnswers(contract), [contract])
}
export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract
contract: ChoiceContract
bets: Bet[]
width: number
height: number
@ -163,18 +111,33 @@ export const ChoiceContractChart = (props: {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const answers = useChartAnswers(contract)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo(
() => [
{ x: new Date(start), y: answers.map((_) => 0) },
const topN = Math.min(CHOICE_ANSWER_COLORS.length, answers.length)
const betPoints = useMemo(
() => getBetPoints(answers, bets, topN),
[answers, bets, topN]
)
const endProbs = useMemo(
() => answers.map((a) => getOutcomeProbability(contract, a.id)),
[answers, contract]
)
const data = useMemo(() => {
const yCount = answers.length > topN ? topN + 1 : topN
const startY = range(0, yCount).map((_) => 0)
const endY =
answers.length > topN
? [...endProbs.slice(0, topN), sum(endProbs.slice(topN))]
: endProbs
return [
{ x: new Date(start), y: startY },
...betPoints,
{
x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)),
y: endY,
},
],
[answers, contract, betPoints, start, end]
)
]
}, [answers.length, topN, betPoints, endProbs, start, end])
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
@ -191,8 +154,8 @@ export const ChoiceContractChart = (props: {
const d = xScale.invert(x)
const legendItems = sortBy(
data.y.map((p, i) => ({
color: CATEGORY_COLORS[i],
label: answers[i].text,
color: CHOICE_ALL_COLORS[i],
label: i === CHOICE_ANSWER_COLORS.length ? 'Other' : answers[i].text,
value: formatPct(p),
p,
})),
@ -224,7 +187,7 @@ export const ChoiceContractChart = (props: {
yScale={yScale}
yKind="percent"
data={data}
colors={CATEGORY_COLORS}
colors={CHOICE_ALL_COLORS}
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip}

View File

@ -219,6 +219,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
@ -325,11 +326,26 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
const newViewXScale = xScale
.copy()
.domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
setViewXScale(() => newViewXScale)
const dataInView = data.filter((p) => {
const x = newViewXScale(p.x)
return x >= 0 && x <= w
})
const yMin = Math.min(...dataInView.map((p) => p.y))
const yMax = Math.max(...dataInView.map((p) => p.y))
// Prevents very small selections from being too zoomed in
if (yMax - yMin > 0.05) {
// adds a little padding to the top and bottom of the selection
yScale.domain([yMin - (yMax - yMin) * 0.1, yMax + (yMax - yMin) * 0.1])
}
} else {
setViewXScale(undefined)
yScale.domain([0, 1])
}
})

View File

@ -9,6 +9,7 @@ import clsx from 'clsx'
import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
@ -168,6 +169,7 @@ export const SVGChart = <X, TT>(props: {
const innerW = w - (margin.left + margin.right)
const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), [])
const isMobile = useIsMobile()
const justSelected = useRef(false)
useEffect(() => {
@ -207,6 +209,15 @@ export const SVGChart = <X, TT>(props: {
}
}
const onTouchMove = (ev: React.TouchEvent) => {
if (onMouseOver) {
const touch = ev.touches[0]
const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left
const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top
onMouseOver(x, y)
}
}
const onPointerLeave = () => {
onMouseLeave?.()
}
@ -222,8 +233,9 @@ export const SVGChart = <X, TT>(props: {
ttParams.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
tooltipMeasure.width ?? 140,
tooltipMeasure.height ?? 35,
isMobile ?? false
)}
>
<Tooltip
@ -242,18 +254,30 @@ export const SVGChart = <X, TT>(props: {
<XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g>
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
{!isMobile ? (
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
) : (
<rect
x="0"
y="0"
width={innerW}
height={innerH}
fill="transparent"
onTouchMove={onTouchMove}
onTouchEnd={onPointerLeave}
/>
)}
</g>
</svg>
</div>
@ -267,23 +291,28 @@ export const getTooltipPosition = (
mouseY: number,
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
tooltipWidth: number,
tooltipHeight: number,
isMobile: boolean
) => {
let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
let bottom = !isMobile
? containerHeight - mouseY + 12
: containerHeight - tooltipHeight + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return { left, bottom }
}

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