diff --git a/components/menu/index.tsx b/components/menu/index.tsx index 015099b9f..3b64a69a7 100644 --- a/components/menu/index.tsx +++ b/components/menu/index.tsx @@ -106,22 +106,15 @@ const TopBar = () => {
{({ active }) => ( - { - event.preventDefault(); - close(); - }} - > + @@ -155,17 +148,13 @@ const TopBar = () => { { - event.preventDefault(); - }} > -
+
-
Leaderboard (coming soon)
+
Leaderboard
diff --git a/lib/constants/foreign-asset.ts b/lib/constants/foreign-asset.ts index de8161f4b..4d0a92c1d 100644 --- a/lib/constants/foreign-asset.ts +++ b/lib/constants/foreign-asset.ts @@ -1,3 +1,4 @@ +import { BaseAssetId, IOForeignAssetId } from "@zeitgeistpm/sdk-next"; import { ChainName } from "./chains"; type ForeignAssetMetadata = { @@ -19,6 +20,17 @@ export const lookupAssetImagePath = (foreignAssetId?: number | null) => { } }; +export const lookupAssetSymbol = (baseAssetId?: BaseAssetId) => { + const foreignAssetId = IOForeignAssetId.is(baseAssetId) + ? baseAssetId.ForeignAsset + : null; + if (foreignAssetId == null) { + return "ZTG"; + } else { + return FOREIGN_ASSET_METADATA[foreignAssetId].tokenSymbol; + } +}; + const BATTERY_STATION_FOREIGN_ASSET_METADATA: ForeignAssetMetadata = { 0: { image: "/currencies/dot.png", diff --git a/lib/util/calc-scalar-winnings.spec.ts b/lib/util/calc-scalar-winnings.spec.ts index 26aafa53e..85abea228 100644 --- a/lib/util/calc-scalar-winnings.spec.ts +++ b/lib/util/calc-scalar-winnings.spec.ts @@ -1,16 +1,41 @@ -import { calcScalarWinnings } from "./calc-scalar-winnings"; +import { + calcScalarResolvedPrices, + calcScalarWinnings, +} from "./calc-scalar-winnings"; describe("calcScalarWinnings", () => { test("should calculate winnings correctly for short tokens", () => { const winnings = calcScalarWinnings(0, 10, 5, 100, 0); expect(winnings.toNumber()).toEqual(50); }); + test("should calculate winnings correctly for long tokens", () => { const winnings = calcScalarWinnings(0, 40, 10, 0, 100); expect(winnings.toNumber()).toEqual(25); }); + test("should calculate winnings correctly for both long and short tokens", () => { const winnings = calcScalarWinnings(0, 40, 10, 100, 100); expect(winnings.toNumber()).toEqual(100); }); + + test("long value should be capped at 1 if resolved outcome is outside of bounds", () => { + const { longTokenValue } = calcScalarResolvedPrices(0, 40, 50); + expect(longTokenValue.toNumber()).toEqual(1); + }); + + test("long value should not fall below 0 if resolved outcome is outside of bounds", () => { + const { longTokenValue } = calcScalarResolvedPrices(10, 40, 0); + expect(longTokenValue.toNumber()).toEqual(0); + }); + + test("short value should not fall below 0 if resolved outcome is outside of bounds", () => { + const { shortTokenValue } = calcScalarResolvedPrices(0, 40, 50); + expect(shortTokenValue.toNumber()).toEqual(0); + }); + + test("short value should be capped at 1 resolved outcome is outside of bounds", () => { + const { shortTokenValue } = calcScalarResolvedPrices(10, 40, 0); + expect(shortTokenValue.toNumber()).toEqual(1); + }); }); diff --git a/lib/util/calc-scalar-winnings.ts b/lib/util/calc-scalar-winnings.ts index d26d4853c..487c644ce 100644 --- a/lib/util/calc-scalar-winnings.ts +++ b/lib/util/calc-scalar-winnings.ts @@ -31,8 +31,20 @@ export const calcScalarResolvedPrices = ( .minus(lowerBound) .div(priceRange); - const longTokenValue = resolvedNumberAsPercentage; - const shortTokenValue = new Decimal(1).minus(resolvedNumberAsPercentage); + const longTokenValue = constrainValue(resolvedNumberAsPercentage); + const shortTokenValue = constrainValue( + new Decimal(1).minus(resolvedNumberAsPercentage), + ); return { longTokenValue, shortTokenValue }; }; + +const constrainValue = (value: Decimal): Decimal => { + if (value.greaterThan(1)) { + return new Decimal(1); + } else if (value.lessThan(0)) { + return new Decimal(0); + } else { + return value; + } +}; diff --git a/package.json b/package.json index a9de6bcfc..80330bbdc 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@zeitgeistpm/avatara-react": "^1.3.2", "@zeitgeistpm/avatara-util": "^1.2.0", "@zeitgeistpm/sdk": "^1.0.3", - "@zeitgeistpm/sdk-next": "npm:@zeitgeistpm/sdk@2.29.20", + "@zeitgeistpm/sdk-next": "npm:@zeitgeistpm/sdk@2.29.21", "@zeitgeistpm/utility": "^2.18.12", "axios": "^0.21.4", "boring-avatars": "^1.6.1", diff --git a/pages/leaderboard.tsx b/pages/leaderboard.tsx new file mode 100644 index 000000000..8a5deb763 --- /dev/null +++ b/pages/leaderboard.tsx @@ -0,0 +1,492 @@ +import { + BaseAssetId, + create, + getMarketIdOf, + IOForeignAssetId, + IOMarketOutcomeAssetId, + IOScalarAssetId, + parseAssetId, + ZeitgeistIpfs, +} from "@zeitgeistpm/sdk-next"; +import Decimal from "decimal.js"; +import { + endpointOptions, + environment, + graphQlEndpoint, + ZTG, +} from "lib/constants"; +import { + FOREIGN_ASSET_METADATA, + lookupAssetSymbol, +} from "lib/constants/foreign-asset"; +import { NextPage } from "next"; +import Link from "next/link"; +import Avatar from "components/ui/Avatar"; +import { formatNumberCompact } from "lib/util/format-compact"; +import { parseAssetIdString } from "lib/util/parse-asset-id"; +import { FullHistoricalAccountBalanceFragment } from "@zeitgeistpm/indexer"; +import { calcScalarResolvedPrices } from "lib/util/calc-scalar-winnings"; + +// Approach: aggregate base asset movements in and out of a market +// "In events": swaps, buy full set +// "Out events": swaps, sell full set, redeem + +type Trade = { + marketId: number; + baseAssetIn: Decimal; + baseAssetOut: Decimal; +}; + +type AccountId = string; + +type Traders = { + [key: AccountId]: Trade[]; +}; + +type MarketBaseDetails = { + baseAssetIn: Decimal; + baseAssetOut: Decimal; +}; + +type MarketTotals = { + [key: MarketId]: MarketBaseDetails; +}; + +type MarketId = number; + +type TradersByMarket = { + [key: AccountId]: MarketTotals; +}; + +type MarketSummary = { + marketId: number; + question: string; + baseAssetId: BaseAssetId; + profit: number; +}; + +type TradersSummary = { + [key: AccountId]: { + profitUsd: number; + markets: MarketSummary[]; + }; +}; + +type Rank = { + accountId: string; + profitUsd: number; + name?: string; + markets: MarketSummary[]; +}; + +type BasePrices = { + [key: string | "ztg"]: [number, number][]; +}; + +const convertEventToTrade = ( + event: FullHistoricalAccountBalanceFragment, + longTokenVaue?: Decimal, + shortTokenVaue?: Decimal, +) => { + const assetId = parseAssetId(event.assetId).unwrap(); + const marketId = IOMarketOutcomeAssetId.is(assetId) + ? getMarketIdOf(assetId) + : undefined; + + if (marketId !== undefined) { + if (event.event === "TokensRedeemed") { + const assetValue = IOScalarAssetId.is(assetId) + ? assetId.ScalarOutcome[1] === "Short" + ? shortTokenVaue + : longTokenVaue + : new Decimal(1); + const trade: Trade = { + marketId, + baseAssetIn: new Decimal(0), + baseAssetOut: new Decimal(event.dBalance).mul(assetValue ?? 1).abs(), + }; + + return trade; + } else if (event.event === "SoldCompleteSet") { + const trade: Trade = { + marketId, + baseAssetIn: new Decimal(0), + baseAssetOut: new Decimal(event.dBalance).abs(), + }; + return trade; + } else if ( + event.event === "Deposited" || + event.event === "DepositedEndowed" || + event.event === "EndowedBoughtCompleteSet" || + event.event === "BoughtCompleteSet" + ) { + const trade: Trade = { + marketId, + baseAssetIn: new Decimal(event.dBalance).abs(), + baseAssetOut: new Decimal(0), + }; + return trade; + } + } +}; +const datesAreOnSameDay = (first: Date, second: Date) => + first.getFullYear() === second.getFullYear() && + first.getMonth() === second.getMonth() && + first.getDate() === second.getDate(); + +const findPrice = (timestamp: number, prices: [number, number][]) => { + const date = new Date(Number(timestamp)); + + const price = prices.find((p) => { + return datesAreOnSameDay(date, new Date(p[0])); + }); + + return price?.[1]; +}; + +const lookupPrice = ( + basePrices: BasePrices, + baseAsset: BaseAssetId, + timestamp: number, +) => { + //BSR has been live before some assets existed, so no price data is available + if (environment === "staging") return 1; + const prices = IOForeignAssetId.is(baseAsset) + ? basePrices[baseAsset.ForeignAsset] + : basePrices["ztg"]; + + return findPrice(timestamp, prices); +}; + +const getBaseAssetHistoricalPrices = async (): Promise => { + const coinGeckoIds = [ + ...Object.values(FOREIGN_ASSET_METADATA).map((asset) => asset.coinGeckoId), + "zeitgeist", + ]; + + const pricesRes = await Promise.all( + coinGeckoIds.map((id) => + fetch( + `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=usd&days=max`, + ), + ), + ); + + const prices = await Promise.all(pricesRes.map((res) => res.json())); + const assetIds = Object.keys(FOREIGN_ASSET_METADATA); + + const pricesObj = prices.reduce((obj, assetPrices, index) => { + obj[assetIds[index]] = assetPrices.prices; + return obj; + }, {}); + + pricesObj["ztg"] = prices.at(-1).prices; + + return pricesObj; +}; + +export async function getStaticProps() { + const sdk = await create({ + provider: endpointOptions.map((e) => e.value), + indexer: graphQlEndpoint, + storage: ZeitgeistIpfs(), + }); + + const basePrices = await getBaseAssetHistoricalPrices(); + + const { markets } = await sdk.indexer.markets(); + + const { historicalSwaps } = await sdk.indexer.historicalSwaps(); + + const tradersWithSwaps = historicalSwaps.reduce((traders, swap) => { + const trades = traders[swap.accountId]; + + const assetInId = parseAssetId(swap.assetIn).unwrap(); + const assetOutId = parseAssetId(swap.assetOut).unwrap(); + let baseAssetSwapType: "in" | "out" | undefined; + + let marketId: number | undefined; + if (IOMarketOutcomeAssetId.is(assetInId)) { + marketId = getMarketIdOf(assetInId); + baseAssetSwapType = "out"; + } else if (IOMarketOutcomeAssetId.is(assetOutId)) { + marketId = getMarketIdOf(assetOutId); + baseAssetSwapType = "in"; + } + + if (marketId === undefined) return traders; + + const trade: Trade = { + marketId, + baseAssetIn: + baseAssetSwapType === "in" + ? new Decimal(swap.assetAmountIn) + : new Decimal(0), + baseAssetOut: + baseAssetSwapType === "out" + ? new Decimal(swap.assetAmountOut) + : new Decimal(0), + }; + + if (trades) { + trades.push(trade); + traders[swap.accountId] = trades; + } else { + traders[swap.accountId] = [trade]; + } + + return traders; + }, {}); + + const { historicalAccountBalances: redeemEvents } = + await sdk.indexer.historicalAccountBalances({ + where: { event_contains: "TokensRedeemed" }, + }); + + const { historicalAccountBalances: buyFullSetEvents } = + await sdk.indexer.historicalAccountBalances({ + where: { + OR: [ + { + event_contains: "BoughtComplete", + }, + { event_contains: "Deposited", assetId_not_contains: "pool" }, + ], + }, + }); + + const { historicalAccountBalances: sellFullSetEvents } = + await sdk.indexer.historicalAccountBalances({ + where: { + event_contains: "SoldComplete", + }, + }); + + const fullSetEvents = [...buyFullSetEvents, ...sellFullSetEvents]; + + const uniqueFullSetEvents = fullSetEvents.reduce< + FullHistoricalAccountBalanceFragment[] + >((uniqueEvents, event) => { + const duplicateEvent = uniqueEvents.find( + (entry) => entry.extrinsic?.hash === event.extrinsic?.hash, + ); + + if (!duplicateEvent) { + uniqueEvents.push(event); + } + + return uniqueEvents; + }, []); + + uniqueFullSetEvents.forEach((event) => { + const trades = tradersWithSwaps[event.accountId]; + + // this check is needed as accounts can aquire tokens via buy full sell or transfer + if (trades) { + const trade = convertEventToTrade(event); + + if (trade) trades.push(trade); + + tradersWithSwaps[event.accountId] = trades; + } + }); + + redeemEvents.forEach((event) => { + const trades = tradersWithSwaps[event.accountId]; + + const assetId = parseAssetIdString(event.assetId); + + const market = IOMarketOutcomeAssetId.is(assetId) + ? markets.find((m) => m.marketId === Number(getMarketIdOf(assetId))) + : null; + if (trades && market) { + const values = + market.marketType.scalar?.[0] != null && + market.marketType.scalar[1] != null && + market.resolvedOutcome != null + ? calcScalarResolvedPrices( + new Decimal(market.marketType.scalar[0]), + new Decimal(market.marketType.scalar[1]), + new Decimal(market.resolvedOutcome), + ) + : { longTokenValue: undefined, shortTokenValue: undefined }; + + const trade = convertEventToTrade( + event, + values.longTokenValue, + values.shortTokenValue, + ); + + if (trade) trades.push(trade); + + tradersWithSwaps[event.accountId] = trades; + } + }); + + //loop through accounts and trades, total up baseAsset in and out for each market + const tradersAggregatedByMarket = Object.keys( + tradersWithSwaps, + ).reduce((traders, accountId) => { + const swaps = tradersWithSwaps[accountId]; + if (!swaps) return traders; + + const marketTotal = swaps.reduce((marketTotals, swap) => { + const total = marketTotals[swap.marketId]; + if (total != null) { + marketTotals[swap.marketId] = { + ...total, + baseAssetIn: swap.baseAssetIn.plus(total.baseAssetIn), + baseAssetOut: swap.baseAssetOut.plus(total.baseAssetOut), + }; + } else { + marketTotals[swap.marketId] = { + baseAssetIn: swap.baseAssetIn, + baseAssetOut: swap.baseAssetOut, + }; + } + return marketTotals; + }, {}); + + return { ...traders, [accountId]: marketTotal }; + }, {}); + + const tradeProfits = Object.keys( + tradersAggregatedByMarket, + ).reduce((ranks, accountId) => { + const trader = tradersAggregatedByMarket[accountId]; + + const marketsSummary: MarketSummary[] = []; + const profit = Object.keys(trader).reduce((total, marketId) => { + const marketTotal: MarketBaseDetails = trader[marketId]; + + const market = markets.find((m) => m.marketId === Number(marketId)); + + const suspiciousActivity = marketTotal.baseAssetIn.eq(0); + + if (market?.status === "Resolved" && !suspiciousActivity) { + const diff = marketTotal.baseAssetOut.minus(marketTotal.baseAssetIn); + + marketsSummary.push({ + question: market.question!, + marketId: market.marketId, + baseAssetId: parseAssetIdString(market.baseAsset) as BaseAssetId, + profit: diff.div(ZTG).toNumber(), + }); + + const endTimestamp = market.period.end; + + const marketEndBaseAssetPrice = lookupPrice( + basePrices, + parseAssetIdString(market.baseAsset) as BaseAssetId, + endTimestamp, + ); + + const usdProfitLoss = diff.mul(marketEndBaseAssetPrice ?? 0); + return total.plus(usdProfitLoss); + } else { + return total; + } + }, new Decimal(0)); + + return { + ...ranks, + [accountId]: { + profitUsd: profit.div(ZTG).toNumber(), + markets: marketsSummary, + }, + }; + }, {}); + + const rankings = Object.keys(tradeProfits) + .reduce((rankings, accountId) => { + rankings.push({ + accountId, + profitUsd: tradeProfits[accountId].profitUsd, + markets: tradeProfits[accountId].markets, + }); + return rankings; + }, []) + .sort((a, b) => b.profitUsd - a.profitUsd); + + const top20 = rankings.slice(0, 20); + + const identities = await Promise.all( + top20.map((player) => sdk.api.query.identity.identityOf(player.accountId)), + ); + + const textDecoder = new TextDecoder(); + + const names: (string | null)[] = identities.map((identity) => + identity.isNone === false + ? textDecoder.decode( + (identity.value.get("info") as any).get("display").value, + ) + : null, + ); + + return { + props: { + rankings: top20.map((player, index) => ({ + ...player, + name: names[index], + })), + revalidate: 10 * 60, //10min + }, + }; +} + +const Leaderboard: NextPage<{ + rankings: Rank[]; +}> = ({ rankings }) => { + return ( +
+
+ Most Profitable Traders +
+
+ {rankings.map((rank, index) => ( +
+
+
+ {index + 1} +
+
+ +
+ + {rank.name ?? rank.accountId} + +
+ ${rank.profitUsd.toFixed(0)} +
+
+ {/*
+ {rank.markets + .sort((a, b) => b.profit - a.profit) + // .slice(0, 1000) // todo move this server side + .map((market) => ( +
+ + {market.question}-{market.marketId} + +
+ {formatNumberCompact(market.profit)}{" "} + {lookupAssetSymbol(market.baseAssetId)} +
+
+ ))} +
*/} +
+ ))} +
+
+ ); +}; + +export default Leaderboard; diff --git a/yarn.lock b/yarn.lock index 987171aa9..8029394c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3082,14 +3082,14 @@ __metadata: languageName: node linkType: hard -"@zeitgeistpm/indexer@npm:^3.0.13": - version: 3.0.13 - resolution: "@zeitgeistpm/indexer@npm:3.0.13" +"@zeitgeistpm/indexer@npm:^3.0.14": + version: 3.0.14 + resolution: "@zeitgeistpm/indexer@npm:3.0.14" dependencies: graphql: ^16.6.0 graphql-request: ^5.0.0 graphql-tag: ^2.12.6 - checksum: d27deb572e1a4aca88493724b5f53b3ad4dc2722c5538e0043f6dc0af8ab9a8622a5e84960fd4810c500e267e26bb57d06d8259e05efe39474df761e2f556207 + checksum: a8f0b65cfc3d563f2f96bb4f2c1257466e7ca1873c136e6fcc350f37829189602958c20394ea8ad84cb1eb09f8acfa17e62279d77b44052f1f46a391e89f22c5 languageName: node linkType: hard @@ -3107,12 +3107,12 @@ __metadata: languageName: node linkType: hard -"@zeitgeistpm/sdk-next@npm:@zeitgeistpm/sdk@2.29.20": - version: 2.29.20 - resolution: "@zeitgeistpm/sdk@npm:2.29.20" +"@zeitgeistpm/sdk-next@npm:@zeitgeistpm/sdk@2.29.21": + version: 2.29.21 + resolution: "@zeitgeistpm/sdk@npm:2.29.21" dependencies: "@zeitgeistpm/augment-api": ^2.11.5 - "@zeitgeistpm/indexer": ^3.0.13 + "@zeitgeistpm/indexer": ^3.0.14 "@zeitgeistpm/rpc": ^2.7.6 "@zeitgeistpm/utility": ^2.18.13 "@zeitgeistpm/web3.storage": ^2.9.13 @@ -3128,7 +3128,7 @@ __metadata: "@polkadot/api": "*" "@polkadot/types": "*" "@polkadot/util": "*" - checksum: c3d986c074e7543efd718a86a02d8c1ea53c083c54ff4ccd48c7873b709c5315ca90b43c0d3584525749e270fa1475e8745a5de7451bd946960a8baa984b335e + checksum: 5e8f28d9fde6c22cfcf67e86661600b1447b31f19471b8ddd238e8c36d68c5d132d4490abd46f1e7db427b9fab04800d46bacbb33e3e052ad350f65b76e6ad3c languageName: node linkType: hard @@ -3227,7 +3227,7 @@ __metadata: "@zeitgeistpm/avatara-react": ^1.3.2 "@zeitgeistpm/avatara-util": ^1.2.0 "@zeitgeistpm/sdk": ^1.0.3 - "@zeitgeistpm/sdk-next": "npm:@zeitgeistpm/sdk@2.29.20" + "@zeitgeistpm/sdk-next": "npm:@zeitgeistpm/sdk@2.29.21" "@zeitgeistpm/utility": ^2.18.12 autoprefixer: 10.2.5 axios: ^0.21.4