improve rewards (#1731)
* don't bias to early zapping so much * untested rewards/leaderboard changes * fix cln dep for payments * make zap proportion scale using quad root * fix for missing proportion on hidden users * improve rewards cutoff criteria * Update api/resolvers/user.js Co-authored-by: ekzyis <ek@stacker.news> * Update api/typeDefs/user.js Co-authored-by: ekzyis <ek@stacker.news> * improve switch readability * small increase in min zap * refresh materialized views on migration --------- Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
parent
6098d39574
commit
6d4dfddae8
|
@ -157,7 +157,7 @@ export default {
|
||||||
const [{ to, from }] = await models.$queryRaw`
|
const [{ to, from }] = await models.$queryRaw`
|
||||||
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
|
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
|
||||||
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
|
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
|
||||||
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
|
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 500 }, { models, ...context })
|
||||||
},
|
},
|
||||||
total: async (parent, args, { models }) => {
|
total: async (parent, args, { models }) => {
|
||||||
if (!parent.total) {
|
if (!parent.total) {
|
||||||
|
|
|
@ -66,11 +66,12 @@ export async function topUsers (parent, { cursor, when, by, from, to, limit = LI
|
||||||
case 'comments': column = 'ncomments'; break
|
case 'comments': column = 'ncomments'; break
|
||||||
case 'referrals': column = 'referrals'; break
|
case 'referrals': column = 'referrals'; break
|
||||||
case 'stacking': column = 'stacked'; break
|
case 'stacking': column = 'stacked'; break
|
||||||
|
case 'value':
|
||||||
default: column = 'proportion'; break
|
default: column = 'proportion'; break
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = (await models.$queryRawUnsafe(`
|
const users = (await models.$queryRawUnsafe(`
|
||||||
SELECT *
|
SELECT * ${column === 'proportion' ? ', proportion' : ''}
|
||||||
FROM
|
FROM
|
||||||
(SELECT users.*,
|
(SELECT users.*,
|
||||||
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
||||||
|
|
|
@ -59,6 +59,11 @@ export default gql`
|
||||||
photoId: Int
|
photoId: Int
|
||||||
since: Int
|
since: Int
|
||||||
|
|
||||||
|
"""
|
||||||
|
this is only returned when we sort stackers by value
|
||||||
|
"""
|
||||||
|
proportion: Float
|
||||||
|
|
||||||
optional: UserOptional!
|
optional: UserOptional!
|
||||||
privates: UserPrivates
|
privates: UserPrivates
|
||||||
|
|
||||||
|
|
|
@ -107,13 +107,13 @@ export function User ({ user, rank, statComps, className = 'mb-2', Embellish, ny
|
||||||
<div className={styles.other}>
|
<div className={styles.other}>
|
||||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||||
</div>}
|
</div>}
|
||||||
{Embellish && <Embellish rank={rank} />}
|
{Embellish && <Embellish rank={rank} user={user} />}
|
||||||
</UserBase>
|
</UserBase>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserHidden ({ rank, Embellish }) {
|
function UserHidden ({ rank, user, Embellish }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rank
|
{rank
|
||||||
|
@ -133,7 +133,7 @@ function UserHidden ({ rank, Embellish }) {
|
||||||
<div className={`${styles.title} text-muted d-inline-flex align-items-center`}>
|
<div className={`${styles.title} text-muted d-inline-flex align-items-center`}>
|
||||||
stacker is in hiding
|
stacker is in hiding
|
||||||
</div>
|
</div>
|
||||||
{Embellish && <Embellish rank={rank} />}
|
{Embellish && <Embellish rank={rank} user={user} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -148,7 +148,7 @@ export function ListUsers ({ users, rank, statComps = DEFAULT_STAT_COMPONENTS, E
|
||||||
{users.map((user, i) => (
|
{users.map((user, i) => (
|
||||||
user
|
user
|
||||||
? <User key={user.id} user={user} rank={rank && i + 1} statComps={statComps} Embellish={Embellish} nymActionDropdown={nymActionDropdown} />
|
? <User key={user.id} user={user} rank={rank && i + 1} statComps={statComps} Embellish={Embellish} nymActionDropdown={nymActionDropdown} />
|
||||||
: <UserHidden key={i} rank={rank && i + 1} Embellish={Embellish} />
|
: <UserHidden key={i} rank={rank && i + 1} user={user} Embellish={Embellish} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -680,7 +680,6 @@ services:
|
||||||
- bitcoin
|
- bitcoin
|
||||||
- sn_lnd
|
- sn_lnd
|
||||||
- lnd
|
- lnd
|
||||||
- cln
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
|
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
@ -254,7 +254,7 @@ export const TOP_USERS = gql`
|
||||||
photoId
|
photoId
|
||||||
ncomments(when: $when, from: $from, to: $to)
|
ncomments(when: $when, from: $from, to: $to)
|
||||||
nposts(when: $when, from: $from, to: $to)
|
nposts(when: $when, from: $from, to: $to)
|
||||||
|
proportion
|
||||||
optional {
|
optional {
|
||||||
stacked(when: $when, from: $from, to: $to)
|
stacked(when: $when, from: $from, to: $to)
|
||||||
spent(when: $when, from: $from, to: $to)
|
spent(when: $when, from: $from, to: $to)
|
||||||
|
|
|
@ -16,7 +16,6 @@ import { useToast } from '@/components/toast'
|
||||||
import { useLightning } from '@/components/lightning'
|
import { useLightning } from '@/components/lightning'
|
||||||
import { ListUsers } from '@/components/user-list'
|
import { ListUsers } from '@/components/user-list'
|
||||||
import { Col, Row } from 'react-bootstrap'
|
import { Col, Row } from 'react-bootstrap'
|
||||||
import { proportions } from '@/lib/madness'
|
|
||||||
import { useData } from '@/components/use-data'
|
import { useData } from '@/components/use-data'
|
||||||
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
|
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
@ -50,6 +49,7 @@ ${ITEM_FULL_FIELDS}
|
||||||
photoId
|
photoId
|
||||||
ncomments
|
ncomments
|
||||||
nposts
|
nposts
|
||||||
|
proportion
|
||||||
|
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
|
@ -117,9 +117,10 @@ export default function Rewards ({ ssrData }) {
|
||||||
|
|
||||||
if (!dat) return <PageLoading />
|
if (!dat) return <PageLoading />
|
||||||
|
|
||||||
function EstimatedReward ({ rank }) {
|
function EstimatedReward ({ rank, user }) {
|
||||||
const referrerReward = Math.floor(total * proportions[rank - 1] * 0.2)
|
if (!user) return null
|
||||||
const reward = Math.floor(total * proportions[rank - 1]) - referrerReward
|
const referrerReward = Math.max(Math.floor(total * user.proportion * 0.2), 0)
|
||||||
|
const reward = Math.max(Math.floor(total * user.proportion) - referrerReward, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='text-muted fst-italic'>
|
<div className='text-muted fst-italic'>
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
CREATE OR REPLACE FUNCTION user_values(
|
||||||
|
min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT,
|
||||||
|
percentile_cutoff INTEGER DEFAULT 50,
|
||||||
|
each_upvote_portion FLOAT DEFAULT 4.0,
|
||||||
|
each_item_portion FLOAT DEFAULT 4.0,
|
||||||
|
handicap_ids INTEGER[] DEFAULT '{616, 6030, 4502, 27}',
|
||||||
|
handicap_zap_mult FLOAT DEFAULT 0.3)
|
||||||
|
RETURNS TABLE (
|
||||||
|
t TIMESTAMP(3), id INTEGER, proportion FLOAT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT period.t, u."userId", u.total_proportion
|
||||||
|
FROM generate_series(min, max, ival) period(t),
|
||||||
|
LATERAL
|
||||||
|
(WITH item_ratios AS (
|
||||||
|
SELECT *,
|
||||||
|
CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
|
||||||
|
CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
|
||||||
|
FROM
|
||||||
|
"Item"
|
||||||
|
WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
|
||||||
|
AND "weightedVotes" > 0
|
||||||
|
AND "deletedAt" IS NULL
|
||||||
|
AND NOT bio
|
||||||
|
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
|
||||||
|
) x
|
||||||
|
WHERE x.percentile <= percentile_cutoff
|
||||||
|
),
|
||||||
|
-- get top upvoters of top posts and comments
|
||||||
|
upvoter_islands AS (
|
||||||
|
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
|
||||||
|
"ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
|
||||||
|
ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
|
||||||
|
- ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
|
||||||
|
FROM item_ratios
|
||||||
|
JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
|
||||||
|
WHERE act = 'TIP'
|
||||||
|
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
|
||||||
|
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
|
||||||
|
),
|
||||||
|
-- isolate contiguous upzaps from the same user on the same item so that when we take the log
|
||||||
|
-- of the upzaps it accounts for successive zaps and does not disproportionately reward them
|
||||||
|
-- quad root of the total tipped
|
||||||
|
upvoters AS (
|
||||||
|
SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(power(sum(tipped) / 1000, 0.25), 0) as tipped, min(acted_at) as acted_at
|
||||||
|
FROM upvoter_islands
|
||||||
|
GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island
|
||||||
|
),
|
||||||
|
-- the relative contribution of each upvoter to the post/comment
|
||||||
|
-- early component: 1/ln(early_rank + e - 1)
|
||||||
|
-- tipped component: how much they tipped relative to the total tipped for the item
|
||||||
|
-- multiplied by the relative rank of the item to the total items
|
||||||
|
-- multiplied by the trust of the user
|
||||||
|
upvoter_ratios AS (
|
||||||
|
SELECT "userId", sum((early_multiplier+tipped_ratio)*ratio*CASE WHEN users.id = ANY (handicap_ids) THEN handicap_zap_mult ELSE users.trust+0.1 END) as upvoter_ratio,
|
||||||
|
"parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
1.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0) - 1) AS early_multiplier,
|
||||||
|
tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio
|
||||||
|
FROM upvoters
|
||||||
|
WHERE tipped > 2.1
|
||||||
|
) u
|
||||||
|
JOIN users on "userId" = users.id
|
||||||
|
GROUP BY "userId", "parentId" IS NULL
|
||||||
|
),
|
||||||
|
proportions AS (
|
||||||
|
SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
|
||||||
|
upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion
|
||||||
|
FROM upvoter_ratios
|
||||||
|
WHERE upvoter_ratio > 0
|
||||||
|
UNION ALL
|
||||||
|
SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion
|
||||||
|
FROM item_ratios
|
||||||
|
)
|
||||||
|
SELECT "userId", sum(proportions.proportion) AS total_proportion
|
||||||
|
FROM proportions
|
||||||
|
GROUP BY "userId"
|
||||||
|
HAVING sum(proportions.proportion) > 0.000001) u;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today;
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days;
|
|
@ -1,6 +1,5 @@
|
||||||
import { notifyEarner } from '@/lib/webPush'
|
import { notifyEarner } from '@/lib/webPush'
|
||||||
import createPrisma from '@/lib/create-prisma'
|
import createPrisma from '@/lib/create-prisma'
|
||||||
import { proportions } from '@/lib/madness'
|
|
||||||
import { SN_NO_REWARDS_IDS } from '@/lib/constants'
|
import { SN_NO_REWARDS_IDS } from '@/lib/constants'
|
||||||
|
|
||||||
const TOTAL_UPPER_BOUND_MSATS = 1_000_000_000
|
const TOTAL_UPPER_BOUND_MSATS = 1_000_000_000
|
||||||
|
@ -40,18 +39,19 @@ export async function earn ({ name }) {
|
||||||
|
|
||||||
/*
|
/*
|
||||||
How earnings (used to) work:
|
How earnings (used to) work:
|
||||||
1/3: top 21% posts over last 36 hours, scored on a relative basis
|
1/3: top 50% posts over last 36 hours, scored on a relative basis
|
||||||
1/3: top 21% comments over last 36 hours, scored on a relative basis
|
1/3: top 50% comments over last 36 hours, scored on a relative basis
|
||||||
1/3: top upvoters of top posts/comments, scored on:
|
1/3: top upvoters of top posts/comments, scored on:
|
||||||
- their trust
|
- their trust
|
||||||
- how much they tipped
|
- how much they tipped
|
||||||
- how early they upvoted it
|
- how early they upvoted it
|
||||||
- how the post/comment scored
|
- how the post/comment scored
|
||||||
|
|
||||||
Now: 80% of earnings go to top 100 stackers by value, and 10% each to their forever and one day referrers
|
Now: 80% of earnings go to top stackers by relative value, and 10% each to their forever and one day referrers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// get earners { userId, id, type, rank, proportion, foreverReferrerId, oneDayReferrerId }
|
// get earners { userId, id, type, rank, proportion, foreverReferrerId, oneDayReferrerId }
|
||||||
|
// has to earn at least 125000 msats to be eligible (so that they get at least 1 sat after referrals)
|
||||||
const earners = await models.$queryRaw`
|
const earners = await models.$queryRaw`
|
||||||
WITH earners AS (
|
WITH earners AS (
|
||||||
SELECT users.id AS "userId", users."referrerId" AS "foreverReferrerId",
|
SELECT users.id AS "userId", users."referrerId" AS "foreverReferrerId",
|
||||||
|
@ -63,8 +63,8 @@ export async function earn ({ name }) {
|
||||||
'day') uv
|
'day') uv
|
||||||
JOIN users ON users.id = uv.id
|
JOIN users ON users.id = uv.id
|
||||||
WHERE NOT (users.id = ANY (${SN_NO_REWARDS_IDS}))
|
WHERE NOT (users.id = ANY (${SN_NO_REWARDS_IDS}))
|
||||||
|
AND uv.proportion >= 0.0000125
|
||||||
ORDER BY proportion DESC
|
ORDER BY proportion DESC
|
||||||
LIMIT 100
|
|
||||||
)
|
)
|
||||||
SELECT earners.*,
|
SELECT earners.*,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
|
@ -86,10 +86,10 @@ export async function earn ({ name }) {
|
||||||
let total = 0
|
let total = 0
|
||||||
|
|
||||||
const notifications = {}
|
const notifications = {}
|
||||||
for (const [i, earner] of earners.entries()) {
|
for (const [, earner] of earners.entries()) {
|
||||||
const foreverReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings
|
const foreverReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings
|
||||||
let oneDayReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings
|
let oneDayReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings
|
||||||
const earnerEarnings = Math.floor(parseFloat(proportions[i] * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings
|
const earnerEarnings = Math.floor(parseFloat(earner.proportion * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings
|
||||||
|
|
||||||
total += earnerEarnings + foreverReferrerEarnings + oneDayReferrerEarnings
|
total += earnerEarnings + foreverReferrerEarnings + oneDayReferrerEarnings
|
||||||
if (total > sum) {
|
if (total > sum) {
|
||||||
|
@ -108,7 +108,7 @@ export async function earn ({ name }) {
|
||||||
'oneDayReferrer', earner.oneDayReferrerId,
|
'oneDayReferrer', earner.oneDayReferrerId,
|
||||||
'oneDayReferrerEarnings', oneDayReferrerEarnings)
|
'oneDayReferrerEarnings', oneDayReferrerEarnings)
|
||||||
|
|
||||||
if (earnerEarnings > 0) {
|
if (earnerEarnings > 1000) {
|
||||||
stmts.push(...earnStmts({
|
stmts.push(...earnStmts({
|
||||||
msats: earnerEarnings,
|
msats: earnerEarnings,
|
||||||
userId: earner.userId,
|
userId: earner.userId,
|
||||||
|
@ -140,7 +140,7 @@ export async function earn ({ name }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (earner.foreverReferrerId && foreverReferrerEarnings > 0) {
|
if (earner.foreverReferrerId && foreverReferrerEarnings > 1000) {
|
||||||
stmts.push(...earnStmts({
|
stmts.push(...earnStmts({
|
||||||
msats: foreverReferrerEarnings,
|
msats: foreverReferrerEarnings,
|
||||||
userId: earner.foreverReferrerId,
|
userId: earner.foreverReferrerId,
|
||||||
|
@ -153,7 +153,7 @@ export async function earn ({ name }) {
|
||||||
oneDayReferrerEarnings += foreverReferrerEarnings
|
oneDayReferrerEarnings += foreverReferrerEarnings
|
||||||
}
|
}
|
||||||
|
|
||||||
if (earner.oneDayReferrerId && oneDayReferrerEarnings > 0) {
|
if (earner.oneDayReferrerId && oneDayReferrerEarnings > 1000) {
|
||||||
stmts.push(...earnStmts({
|
stmts.push(...earnStmts({
|
||||||
msats: oneDayReferrerEarnings,
|
msats: oneDayReferrerEarnings,
|
||||||
userId: earner.oneDayReferrerId,
|
userId: earner.oneDayReferrerId,
|
||||||
|
|
Loading…
Reference in New Issue