diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index 11c5252b58..84ca02bfdc 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -31,6 +31,7 @@ import { FillCreateObject, FillType, FundingIndexUpdatesCreateObject, + LeaderboardPnlCreateObject, Liquidity, LiquidityTiersCreateObject, MarketCreateObject, @@ -855,3 +856,37 @@ export const duplicatedSubaccountUsername: SubaccountUsernamesCreateObject = { username: 'LyingRaisin32', subaccountId: defaultSubaccountId3, }; + +// ============== Leaderboard pnl Data ============== + +export const defaultLeaderboardPnlOneDay: LeaderboardPnlCreateObject = { + subaccountId: defaultSubaccountId, + timeSpan: 'ONE_DAY', + pnl: '10000', + currentEquity: '1000', + rank: 1, +}; + +export const defaultLeaderboardPnl2OneDay: LeaderboardPnlCreateObject = { + subaccountId: defaultSubaccountId2, + timeSpan: 'ONE_DAY', + pnl: '100', + currentEquity: '10000', + rank: 2, +}; + +export const defaultLeaderboardPnl1AllTime: LeaderboardPnlCreateObject = { + subaccountId: defaultSubaccountId, + timeSpan: 'ALL_TIME', + pnl: '10000', + currentEquity: '1000', + rank: 1, +}; + +export const defaultLeaderboardPnlOneDayToUpsert: LeaderboardPnlCreateObject = { + subaccountId: defaultSubaccountId, + timeSpan: 'ONE_DAY', + pnl: '100000', + currentEquity: '1000', + rank: 1, +}; diff --git a/indexer/packages/postgres/__tests__/stores/leaderboard-pnl-table.test.ts b/indexer/packages/postgres/__tests__/stores/leaderboard-pnl-table.test.ts new file mode 100644 index 0000000000..85d1eb6f97 --- /dev/null +++ b/indexer/packages/postgres/__tests__/stores/leaderboard-pnl-table.test.ts @@ -0,0 +1,95 @@ +import { LeaderboardPnlFromDatabase } from '../../src/types'; +import * as LeaderboardPnlTable from '../../src/stores/leaderboard-pnl-table'; +import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; +import { + defaultLeaderboardPnl2OneDay, + defaultLeaderboardPnlOneDay, + defaultLeaderboardPnl1AllTime, + defaultLeaderboardPnlOneDayToUpsert, +} from '../helpers/constants'; +import { seedData } from '../helpers/mock-generators'; + +describe('LeaderboardPnl store', () => { + beforeEach(async () => { + await seedData(); + }); + + beforeAll(async () => { + await migrate(); + }); + + afterEach(async () => { + await clearData(); + }); + + afterAll(async () => { + await teardown(); + }); + + it('Successfully creates a LeaderboardPnl', async () => { + await LeaderboardPnlTable.create(defaultLeaderboardPnlOneDay); + }); + + it('Successfully creates multiple LeaderboardPnls', async () => { + await Promise.all([ + LeaderboardPnlTable.create(defaultLeaderboardPnlOneDay), + LeaderboardPnlTable.create(defaultLeaderboardPnl2OneDay), + LeaderboardPnlTable.create(defaultLeaderboardPnl1AllTime), + ]); + + const leaderboardPnls: LeaderboardPnlFromDatabase[] = await LeaderboardPnlTable.findAll( + {}, + [], + ); + + expect(leaderboardPnls.length).toEqual(3); + }); + + it('Successfully finds LeaderboardPnl with subaccountId and timespan', async () => { + await Promise.all([ + LeaderboardPnlTable.create(defaultLeaderboardPnlOneDay), + LeaderboardPnlTable.create(defaultLeaderboardPnl2OneDay), + LeaderboardPnlTable.create(defaultLeaderboardPnl1AllTime), + ]); + + const leaderboardPnl: LeaderboardPnlFromDatabase[] = await LeaderboardPnlTable.findAll( + { + subaccountId: [defaultLeaderboardPnlOneDay.subaccountId], + timeSpan: [defaultLeaderboardPnlOneDay.timeSpan], + }, + [], + ); + + expect(leaderboardPnl.length).toEqual(1); + expect(leaderboardPnl[0]).toEqual(expect.objectContaining(defaultLeaderboardPnlOneDay)); + }); + + it('Successfully upserts a LeaderboardPnl', async () => { + await LeaderboardPnlTable.upsert(defaultLeaderboardPnlOneDay); + + await LeaderboardPnlTable.upsert(defaultLeaderboardPnlOneDayToUpsert); + + const leaderboardPnls: LeaderboardPnlFromDatabase[] = await LeaderboardPnlTable.findAll( + {}, + [], + ); + + expect(leaderboardPnls.length).toEqual(1); + expect(leaderboardPnls[0]).toEqual( + expect.objectContaining(defaultLeaderboardPnlOneDayToUpsert)); + }); + + it('Successfully bulk upserts LeaderboardPnls', async () => { + await LeaderboardPnlTable.bulkUpsert( + [defaultLeaderboardPnlOneDay, defaultLeaderboardPnl2OneDay]); + + const leaderboardPnls: LeaderboardPnlFromDatabase[] = await LeaderboardPnlTable.findAll( + {}, + [], + ); + + expect(leaderboardPnls.length).toEqual(2); + expect(leaderboardPnls[0]).toEqual(expect.objectContaining(defaultLeaderboardPnlOneDay)); + expect(leaderboardPnls[1]).toEqual(expect.objectContaining(defaultLeaderboardPnl2OneDay)); + }); +}); diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20240717160024_create_leaderboard_pnl_table.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20240717160024_create_leaderboard_pnl_table.ts new file mode 100644 index 0000000000..47fb10f476 --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20240717160024_create_leaderboard_pnl_table.ts @@ -0,0 +1,25 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('leaderboard_pnl', (table) => { + table.uuid('subaccountId').notNullable().references('id').inTable('subaccounts'); + table.enum( + 'timeSpan', + [ + 'ONE_DAY', + 'SEVEN_DAYS', + 'THIRTY_DAYS', + 'ONE_YEAR', + 'ALL_TIME', + ], + ); + table.string('pnl').notNullable(); + table.string('currentEquity').notNullable(); + table.integer('rank').notNullable(); + table.primary(['subaccountId', 'timeSpan']); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('leaderboard_pnl'); +} diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20240717171246_create_leaderboard_pnl_rank_timespan_index.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20240717171246_create_leaderboard_pnl_rank_timespan_index.ts new file mode 100644 index 0000000000..3c9b83ab9a --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20240717171246_create_leaderboard_pnl_rank_timespan_index.ts @@ -0,0 +1,17 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "leaderboard_pnl_rank_timespan_index" ON leaderboard_pnl("rank", "timeSpan"); + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(` + DROP INDEX CONCURRENTLY IF EXISTS "leaderboard_pnl_rank_timespan_index"; + `); +} + +export const config = { + transaction: false, +}; diff --git a/indexer/packages/postgres/src/helpers/db-helpers.ts b/indexer/packages/postgres/src/helpers/db-helpers.ts index 7e6234eb48..65949a5407 100644 --- a/indexer/packages/postgres/src/helpers/db-helpers.ts +++ b/indexer/packages/postgres/src/helpers/db-helpers.ts @@ -9,6 +9,7 @@ const layer2Tables = [ 'perpetual_positions', 'fills', 'subaccount_usernames', + 'leaderboard_pnl', ]; const layer1Tables = [ diff --git a/indexer/packages/postgres/src/index.ts b/indexer/packages/postgres/src/index.ts index 218d8af1db..5ea7775956 100644 --- a/indexer/packages/postgres/src/index.ts +++ b/indexer/packages/postgres/src/index.ts @@ -17,6 +17,7 @@ export { default as TransferModel } from './models/transfer-model'; export { default as TradingRewardModel } from './models/trading-reward-model'; export { default as TradingRewardAggregationModel } from './models/trading-reward-aggregation-model'; export { default as SubaccountUsernamesModel } from './models/subaccount-usernames-model'; +export { default as LeaderboardPnlModel } from './models/leaderboard-pnl-model'; export * as AssetTable from './stores/asset-table'; export * as AssetPositionTable from './stores/asset-position-table'; @@ -40,6 +41,7 @@ export * as ComplianceTable from './stores/compliance-table'; export * as ComplianceStatusTable from './stores/compliance-status-table'; export * as TradingRewardTable from './stores/trading-reward-table'; export * as TradingRewardAggregationTable from './stores/trading-reward-aggregation-table'; +export * as LeaderboardPnlTable from './stores/leaderboard-pnl-table'; export * as SubaccountUsernamesTable from './stores/subaccount-usernames-table'; export * as perpetualMarketRefresher from './loops/perpetual-market-refresher'; diff --git a/indexer/packages/postgres/src/models/leaderboard-pnl-model.ts b/indexer/packages/postgres/src/models/leaderboard-pnl-model.ts new file mode 100644 index 0000000000..7c83f6872e --- /dev/null +++ b/indexer/packages/postgres/src/models/leaderboard-pnl-model.ts @@ -0,0 +1,61 @@ +import path from 'path'; + +import { Model } from 'objection'; + +import { NumericPattern } from '../lib/validators'; +import UpsertQueryBuilder from '../query-builders/upsert'; +import BaseModel from './base-model'; + +export default class LeaderboardPnlModel extends BaseModel { + + static get tableName() { + return 'leaderboard_pnl'; + } + + static get idColumn() { + return ['subaccountId', 'timeSpan']; + } + + static relationMappings = { + subaccount: { + relation: Model.BelongsToOneRelation, + modelClass: path.join(__dirname, 'subaccount-model'), + join: { + from: 'leaderboard_pnl.subaccountId', + to: 'subaccounts.id', + }, + }, + }; + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'subaccountId', + 'timeSpan', + 'pnl', + 'currentEquity', + 'rank', + ], + properties: { + subaccountId: { type: 'string' }, + timeSpan: { type: 'string' }, + pnl: { type: 'string', pattern: NumericPattern }, + currentEquity: { type: 'string', pattern: NumericPattern }, + rank: { type: 'integer' }, + }, + }; + } + + subaccountId!: string; + + timeSpan!: string; + + QueryBuilderType!: UpsertQueryBuilder; + + pnl!: string; + + currentEquity!: string; + + rank!: number; +} diff --git a/indexer/packages/postgres/src/stores/leaderboard-pnl-table.ts b/indexer/packages/postgres/src/stores/leaderboard-pnl-table.ts new file mode 100644 index 0000000000..6dbf33752d --- /dev/null +++ b/indexer/packages/postgres/src/stores/leaderboard-pnl-table.ts @@ -0,0 +1,145 @@ +import Knex from 'knex'; +import _ from 'lodash'; +import { QueryBuilder } from 'objection'; + +import { DEFAULT_POSTGRES_OPTIONS } from '../constants'; +import { knexPrimary } from '../helpers/knex'; +import { + verifyAllRequiredFields, + setupBaseQuery, + verifyAllInjectableVariables, + setBulkRowsForUpdate, + generateBulkUpsertString, +} from '../helpers/stores-helpers'; +import Transaction from '../helpers/transaction'; +import LeaderboardPnlModel from '../models/leaderboard-pnl-model'; +import { + QueryConfig, + LeaderboardPnlCreateObject, + LeaderboardPnlFromDatabase, + LeaderboardPnlColumns, + LeaderboardPnlQueryConfig, + Options, + Ordering, + QueryableField, +} from '../types'; + +export async function findAll( + { + subaccountId, + timeSpan, + rank, + limit, + }: LeaderboardPnlQueryConfig, + requiredFields: QueryableField[], + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + verifyAllRequiredFields( + { + subaccountId, + timeSpan, + rank, + limit, + } as QueryConfig, + requiredFields, + ); + + let baseQuery: QueryBuilder = setupBaseQuery( + LeaderboardPnlModel, + options, + ); + + if (subaccountId) { + baseQuery = baseQuery.whereIn(LeaderboardPnlColumns.subaccountId, subaccountId); + } + + if (timeSpan) { + baseQuery = baseQuery.whereIn(LeaderboardPnlColumns.timeSpan, timeSpan); + } + + if (rank) { + baseQuery = baseQuery.whereIn(LeaderboardPnlColumns.rank, rank); + } + + if (options.orderBy !== undefined) { + for (const [column, order] of options.orderBy) { + baseQuery = baseQuery.orderBy( + column, + order, + ); + } + } else { + baseQuery = baseQuery.orderBy( + LeaderboardPnlColumns.rank, + Ordering.ASC, + ); + } + + if (limit) { + baseQuery = baseQuery.limit(limit); + } + + return baseQuery.returning('*'); +} + +export async function create( + leaderboardPnlToCreate: LeaderboardPnlCreateObject, + options: Options = { txId: undefined }, +): Promise { + return LeaderboardPnlModel.query( + Transaction.get(options.txId), + ).insert({ + ...leaderboardPnlToCreate, + }).returning('*'); +} + +export async function upsert( + LeaderboardPnlToUpsert: LeaderboardPnlCreateObject, + options: Options = { txId: undefined }, +): Promise { + const leaderboardPnls: LeaderboardPnlModel[] = await LeaderboardPnlModel.query( + Transaction.get(options.txId), + ).upsert({ + ...LeaderboardPnlToUpsert, + }).returning('*'); + return leaderboardPnls[0]; +} + +export async function bulkUpsert( + leaderboardPnlObjects: LeaderboardPnlCreateObject[], + options: Options = { txId: undefined }, +): Promise { + leaderboardPnlObjects.forEach( + (leaderboardPnlObject: LeaderboardPnlCreateObject) => verifyAllInjectableVariables( + Object.values(leaderboardPnlObject), + ), + ); + + const columns: LeaderboardPnlColumns[] = _.keys( + leaderboardPnlObjects[0]) as LeaderboardPnlColumns[]; + const rows: string[] = setBulkRowsForUpdate({ + objectArray: leaderboardPnlObjects, + columns, + numericColumns: [ + LeaderboardPnlColumns.rank, + ], + stringColumns: [ + LeaderboardPnlColumns.subaccountId, + LeaderboardPnlColumns.timeSpan, + LeaderboardPnlColumns.currentEquity, + LeaderboardPnlColumns.pnl, + ], + }); + + const query: string = generateBulkUpsertString({ + table: LeaderboardPnlModel.tableName, + objectRows: rows, + columns, + uniqueIdentifiers: [LeaderboardPnlColumns.subaccountId, LeaderboardPnlColumns.timeSpan], + }); + + const transaction: Knex.Transaction | undefined = Transaction.get(options.txId); + return transaction + ? knexPrimary.raw(query).transacting(transaction) + : knexPrimary.raw(query); +} diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index ba8a3bdf41..bee5287c24 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -260,6 +260,14 @@ export interface SubaccountUsernamesFromDatabase { subaccountId: string; } +export interface LeaderboardPnlFromDatabase { + subaccountId: string; + timeSpan: string; + pnl: string; + currentEquity: string; + rank: number; +} + export type SubaccountAssetNetTransferMap = { [subaccountId: string]: { [assetId: string]: string } }; export type SubaccountToPerpetualPositionsMap = { [subaccountId: string]: diff --git a/indexer/packages/postgres/src/types/index.ts b/indexer/packages/postgres/src/types/index.ts index a2fc108dd5..53f1b3c397 100644 --- a/indexer/packages/postgres/src/types/index.ts +++ b/indexer/packages/postgres/src/types/index.ts @@ -27,4 +27,5 @@ export * from './trading-reward-types'; export * from './trading-reward-aggregation-types'; export * from './pagination-types'; export * from './subaccount-usernames-types'; +export * from './leaderboard-pnl-types'; export { PositionSide } from './position-types'; diff --git a/indexer/packages/postgres/src/types/leaderboard-pnl-types.ts b/indexer/packages/postgres/src/types/leaderboard-pnl-types.ts new file mode 100644 index 0000000000..5441b3d01b --- /dev/null +++ b/indexer/packages/postgres/src/types/leaderboard-pnl-types.ts @@ -0,0 +1,17 @@ +/* ------- LEADERBOARD PNL TYPES ------- */ + +export interface LeaderboardPnlCreateObject { + subaccountId: string; + pnl: string; + timeSpan: string; + currentEquity: string; + rank: number; +} + +export enum LeaderboardPnlColumns { + subaccountId = 'subaccountId', + timeSpan = 'timeSpan', + pnl = 'pnl', + currentEquity = 'currentEquity', + rank = 'rank', +} diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index 71cf1fd4cd..b7271d34c1 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -86,6 +86,8 @@ export enum QueryableField { STARTED_AT_HEIGHT_BEFORE_OR_AT = 'startedAtHeightBeforeOrAt', REASON = 'reason', USERNAME = 'username', + TIMESPAN = 'timeSpan', + RANK = 'rank', } export interface QueryConfig { @@ -313,3 +315,9 @@ export interface TradingRewardAggregationQueryConfig extends QueryConfig { [QueryableField.STARTED_AT_BEFORE_OR_AT]?: IsoString; [QueryableField.STARTED_AT_HEIGHT_BEFORE_OR_AT]?: string; } + +export interface LeaderboardPnlQueryConfig extends QueryConfig { + [QueryableField.SUBACCOUNT_ID]?: string[]; + [QueryableField.TIMESPAN]?: string[]; + [QueryableField.RANK]?: number[]; +}