diff --git a/apps/api/app/api/v1/lists/[id]/graph/route.ts b/apps/api/app/api/v1/lists/[id]/graph/route.ts new file mode 100644 index 00000000..6c15079c --- /dev/null +++ b/apps/api/app/api/v1/lists/[id]/graph/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import type { SchemaResponse } from '@play-money/api-helpers' +import { getList } from '@play-money/lists/lib/getList' +import { getListTransactionsTimeSeries } from '@play-money/lists/lib/getListTransactionsTimeSeries' +import schema from './schema' + +export const dynamic = 'force-dynamic' + +export async function GET( + _req: Request, + { params }: { params: unknown } +): Promise> { + try { + const { id } = schema.get.parameters.parse(params) + await getList({ id }) + + const data = await getListTransactionsTimeSeries({ + listId: id, + tickInterval: 1, + endAt: new Date(), + excludeTransactionTypes: ['TRADE_LOSS', 'TRADE_WIN', 'LIQUIDITY_RETURNED'], + }) + + return NextResponse.json({ + data, + }) + } catch (error) { + console.log(error) // eslint-disable-line no-console -- Log error for debugging + return NextResponse.json({ error: 'Error processing request' }, { status: 500 }) + } +} diff --git a/apps/api/app/api/v1/lists/[id]/graph/schema.ts b/apps/api/app/api/v1/lists/[id]/graph/schema.ts new file mode 100644 index 00000000..123d93be --- /dev/null +++ b/apps/api/app/api/v1/lists/[id]/graph/schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { ApiEndpoints, ServerErrorSchema } from '@play-money/api-helpers' + +export default { + get: { + summary: 'Get the graph for a List of Markets', + parameters: z.object({ id: z.string() }), + responses: { + 200: z.object({ + data: z.array( + z.object({ + startAt: z.date(), + endAt: z.date(), + markets: z.array( + z.object({ + id: z.string(), + probability: z.number(), + }) + ), + }) + ), + }), + 404: ServerErrorSchema, + 500: ServerErrorSchema, + }, + }, +} as const satisfies ApiEndpoints diff --git a/packages/api-helpers/client/hooks.ts b/packages/api-helpers/client/hooks.ts index 8e3efecf..eb1c7b10 100644 --- a/packages/api-helpers/client/hooks.ts +++ b/packages/api-helpers/client/hooks.ts @@ -76,6 +76,22 @@ export function useMarketGraph({ marketId }: { marketId: string }) { }>(MARKET_GRAPH_PATH(marketId), { refreshInterval: FIVE_MINUTES }) } +export function LIST_GRAPH_PATH(listId: string) { + return `/v1/lists/${listId}/graph` +} +export function useListGraph({ listId }: { listId: string }) { + return useSWR<{ + data: Array<{ + startAt: Date + endAt: Date + markets: Array<{ + id: string + probability: number + }> + }> + }>(LIST_GRAPH_PATH(listId), { refreshInterval: FIVE_MINUTES }) +} + export function useMarketRelated({ marketId }: { marketId: string }) { return useSWR<{ data: Array diff --git a/packages/database/README.md b/packages/database/README.md index 4f7e816f..dfe52c74 100644 --- a/packages/database/README.md +++ b/packages/database/README.md @@ -69,3 +69,10 @@ The **Play Money** database package manages the platform's database interactions ```bash npx prisma migrate dev --name migration_name ``` + +5. **Running One-Off Scripts**: + + - To run any one-off scripts, such as sending a user a gift, use the following command: + ```bash + npx dotenv -- npx tsx ./packages/database/scripts/send-user-gift.ts + ``` diff --git a/packages/lists/components/ListGraph.tsx b/packages/lists/components/ListGraph.tsx new file mode 100644 index 00000000..09a4ad02 --- /dev/null +++ b/packages/lists/components/ListGraph.tsx @@ -0,0 +1,123 @@ +import { format } from 'date-fns' +import _ from 'lodash' +import React from 'react' +import { LineChart, Line, ResponsiveContainer, YAxis, XAxis, CartesianGrid, Tooltip as ChartTooltip } from 'recharts' +import { useListGraph, useMarketGraph } from '@play-money/api-helpers/client/hooks' +import { formatProbability } from '@play-money/markets/components/MarketProbabilityDetail' +import { Card } from '@play-money/ui/card' +import { ExtendedList } from '../types' + +function CustomizedXAxisTick({ x, y, payload }: { x: number; y: number; payload: { value: string } }) { + return ( + + + {format(payload.value, 'MMM d')} + + + ) +} + +function CustomizedYAxisTick({ x, y, payload }: { x: number; y: number; payload: { value: string | number } }) { + return payload.value !== 0 ? ( + + + {payload.value}% + + + ) : ( + + ) +} + +export function ListGraph({ list, activeOptionId }: { list: ExtendedList; activeOptionId: string }) { + const { data: graph } = useListGraph({ listId: list.id }) + const activeMarketIndex = list.markets.findIndex((m) => m.market.id === activeOptionId) + + return ( + + {graph?.data ? ( + + + { + const data = payload?.[0]?.payload + if (data) { + return ( + +
{format(data.startAt, 'MMM d, yyyy')}
+ {list.markets.map((market, i) => { + const dataOption = data.markets.find( + (o: { id: string; proability: number }) => o.id === market.market.id + ) + return ( +
+ {market.market.question}: {formatProbability(dataOption.probability)} +
+ ) + })} +
+ ) + } + return null + }} + /> + format(value, 'MMM d')} + /> + (value !== 0 && value !== 100 ? `${value}%` : '')} + /> + {list.markets.map((market, i) => ( + { + const dataOption = data.markets.find( + (o: { id: string; proability: number }) => o.id === market.market.id + ) + return dataOption.probability + }} + stroke={market.market.options[0].color} + opacity={0.4} + strokeWidth={2.5} + strokeLinejoin="round" + animationDuration={750} + /> + ))} + {activeMarketIndex !== -1 ? ( + { + const activeOption = data.markets.find( + (option: { id: string; proability: number }) => option.id === activeOptionId + ) + return activeOption.probability + }} + stroke={list.markets[activeMarketIndex].market.options[0].color} + strokeWidth={2.5} + strokeLinejoin="round" + animationDuration={750} + /> + ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/packages/lists/components/ListPage.tsx b/packages/lists/components/ListPage.tsx index e96959b7..9a67156f 100644 --- a/packages/lists/components/ListPage.tsx +++ b/packages/lists/components/ListPage.tsx @@ -21,6 +21,7 @@ import { canAddToList, canModifyList } from '../rules' import { ExtendedList } from '../types' import { AddMoreListDialog } from './AddMoreListDialog' import { EditListDialog } from './EditListDialog' +import { ListGraph } from './ListGraph' import { ListMarketRow } from './ListMarketRow' import { ListToolbar } from './ListToolbar' @@ -77,6 +78,10 @@ export function ListPage({ + + + + {list.markets.length ? ( @@ -117,13 +122,21 @@ export function ListPage({ - {list.tags.length ? ( + {list.markets.length ? (
- {list.tags.map((tag) => ( - - {tag} - - ))} + {_(list.markets) + .flatMap((market) => market.market.tags) + .countBy() + .toPairs() + .sortBy(1) + .reverse() + .take(5) + .map(([tag, count]) => ( + + {tag} + + )) + .value()}
) : null}
diff --git a/packages/lists/lib/getListTransactionsTimeSeries.ts b/packages/lists/lib/getListTransactionsTimeSeries.ts new file mode 100644 index 00000000..5ce011c2 --- /dev/null +++ b/packages/lists/lib/getListTransactionsTimeSeries.ts @@ -0,0 +1,165 @@ +import Decimal from 'decimal.js' +import db from '@play-money/database' +import { TransactionTypeType } from '@play-money/database/zod/inputTypeSchemas/TransactionTypeSchema' +import { calculateProbability } from '@play-money/finance/amms/maniswap-v1.1' +import { distributeRemainder } from '@play-money/finance/lib/helpers' +import { getList } from '@play-money/lists/lib/getList' +import { getMarketAmmAccount } from '@play-money/markets/lib/getMarketAmmAccount' +import { MarketTransaction } from '@play-money/markets/lib/getMarketTransactions' + +type Bucket = { + startAt: Date + endAt: Date + transactions: Array + markets: Array<{ + id: string + probability: Decimal + options: Array<{ + id: string + probability: Decimal + shares: Decimal + }> + }> +} + +export async function getListTransactionsTimeSeries({ + listId, + startAt, + endAt = new Date(), + tickInterval = 24, // in hours + excludeTransactionTypes, +}: { + listId: string + startAt?: Date + endAt?: Date + tickInterval?: number + excludeTransactionTypes?: Array +}) { + const list = await getList({ id: listId, extended: true }) + + // Get AMM accounts for all markets in the list + const ammAccounts = await Promise.all( + list.markets.map((market) => getMarketAmmAccount({ marketId: market.market.id })) + ) + + if (!startAt) { + startAt = new Date(list.createdAt.getTime() - 60000) // Subtracting a minute to account for legacy list creation + } + + const tickIntervalMs = tickInterval * 60 * 60 * 1000 + const numBuckets = Math.ceil((endAt.getTime() - startAt.getTime()) / tickIntervalMs) + + const buckets = Array.from( + { length: numBuckets }, + (_, i) => + ({ + startAt: new Date(startAt.getTime() + i * tickIntervalMs), + endAt: new Date(startAt.getTime() + (i + 1) * tickIntervalMs), + transactions: [], + markets: list.markets.map((market) => ({ + options: market.market.options.map((option) => ({ + id: option.id, + probability: new Decimal(0), + shares: new Decimal(0), + })), + probability: new Decimal(0), + id: market.market.id, + })), + }) as Bucket + ) + + // Get transactions for all markets in the list + const transactions = await db.transaction.findMany({ + where: { + marketId: { + in: list.markets.map((market) => market.market.id), + }, + createdAt: { + gte: startAt, + lte: endAt, + }, + type: { + notIn: excludeTransactionTypes, + }, + isReverse: null, + }, + include: { + entries: true, + }, + }) + + transactions.forEach((transaction) => { + const transactionTime = transaction.createdAt.getTime() + const bucketIndex = Math.floor((transactionTime - startAt.getTime()) / tickIntervalMs) + if (bucketIndex >= 0 && bucketIndex < numBuckets) { + buckets[bucketIndex].transactions.push(transaction) + } + }) + + buckets.forEach((bucket, i) => { + // Start with the previous bucket's shares + if (i > 0) { + const lastBucket = buckets[i - 1] + bucket.markets = bucket.markets.map((market) => { + const lastBucketMarket = lastBucket.markets.find((m) => market.id === m.id)! + + return { + ...market, + options: market.options.map((option) => ({ + ...option, + shares: lastBucketMarket.options.find((o) => o.id === option.id)!.shares, + })), + } + }) + } + + bucket.transactions.forEach((transaction) => { + transaction.entries.forEach( + (item: { assetId: string; toAccountId: string; fromAccountId: string; amount: Decimal }) => { + const market = bucket.markets.find((m) => m.id === transaction.marketId) + const option = market?.options.find((o) => o.id === item.assetId) + const ammAccount = ammAccounts.find((acc) => acc.marketId === transaction.marketId) + + if (!market || !option || !ammAccount) { + return + } + + if (item.toAccountId === ammAccount.id) { + option.shares = option.shares.plus(item.amount) + } + if (item.fromAccountId === ammAccount.id) { + option.shares = option.shares.minus(item.amount) + } + } + ) + }) + + bucket.markets = bucket.markets.map((market, i) => { + const shares = market.options.map((option) => option.shares) + const probabilities = market.options.map((_market, i) => { + return calculateProbability({ index: i, shares }) + }) + const distributed = distributeRemainder(probabilities) + + return { + ...market, + probability: distributed[0], + options: market.options.map((option, i) => ({ + ...option, + probability: distributed[i], + })), + } + }) + }) + + const timeSeriesData = buckets.map((bucket) => ({ + startAt: bucket.startAt, + endAt: bucket.endAt, + markets: bucket.markets.map((market) => ({ + id: market.id, + probability: market.probability.toNumber(), + })), + })) + + return timeSeriesData +} diff --git a/packages/markets/components/MarketToolbar.tsx b/packages/markets/components/MarketToolbar.tsx index f5de77c8..505ec5a3 100644 --- a/packages/markets/components/MarketToolbar.tsx +++ b/packages/markets/components/MarketToolbar.tsx @@ -3,6 +3,7 @@ import { MoreVertical, Link, Pencil } from 'lucide-react' import { useRouter, usePathname, useSearchParams } from 'next/navigation' import React, { useCallback } from 'react' +import { updateMarket } from '@play-money/api-helpers/client' import { Button } from '@play-money/ui/button' import { DropdownMenu, @@ -78,6 +79,11 @@ export function MarketToolbar({ } } + const handleHalt = async () => { + await updateMarket({ marketId: market.id, body: { closeDate: new Date() } }) + onRevalidate?.() + } + return (
{canEdit ? ( @@ -113,6 +119,12 @@ export function MarketToolbar({ {canResolve ? ( <> + new Date(market.closeDate)} + > + Halt trading + { setResolving('true') diff --git a/packages/markets/lib/createLiquidityVolumeBonusTransaction.test.ts b/packages/markets/lib/createLiquidityVolumeBonusTransaction.test.ts index 51f226e1..860dd1c1 100644 --- a/packages/markets/lib/createLiquidityVolumeBonusTransaction.test.ts +++ b/packages/markets/lib/createLiquidityVolumeBonusTransaction.test.ts @@ -7,6 +7,7 @@ import { mockTransactionEntry, mockTransactionWithEntries, } from '@play-money/database/mocks' +import { LIQUIDITY_VOLUME_BONUS_PERCENT } from '@play-money/finance/economy' import { executeTransaction } from '@play-money/finance/lib/executeTransaction' import { getHouseAccount } from '@play-money/finance/lib/getHouseAccount' import { createLiquidityVolumeBonusTransaction } from './createLiquidityVolumeBonusTransaction' @@ -26,6 +27,19 @@ describe('createLiquidityVolumeBonusTransaction', () => { jest.clearAllMocks() }) + if (!LIQUIDITY_VOLUME_BONUS_PERCENT) { + it('should not create a transaction if the bonus is 0', async () => { + await createLiquidityVolumeBonusTransaction({ + marketId: 'market-1', + amountTraded: new Decimal(100), + }) + + expect(executeTransaction).not.toHaveBeenCalled() + }) + + return + } + it('should handle a single LP', async () => { jest.mocked(getMarketAmmAccount).mockResolvedValue(mockAccount({ id: 'amm-1-account' })) jest.mocked(getMarketClearingAccount).mockResolvedValue(mockAccount({ id: 'EXCHANGER' }))