diff --git a/indexer/packages/postgres/__tests__/stores/pnl-ticks-table.test.ts b/indexer/packages/postgres/__tests__/stores/pnl-ticks-table.test.ts index 2fb699acbf..25fd8f0959 100644 --- a/indexer/packages/postgres/__tests__/stores/pnl-ticks-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/pnl-ticks-table.test.ts @@ -460,22 +460,19 @@ describe('PnlTicks store', () => { interval, 7 * 24 * 60 * 60, // 1 week [defaultSubaccountId, defaultSubaccountIdWithAlternateAddress], + DateTime.fromISO(createdTicks[8].blockTime).plus({ seconds: 1 }), ); // See setup function for created ticks. // Should exclude tick that is within the same hour except the first. const expectedHourlyTicks: PnlTicksFromDatabase[] = [ - createdTicks[8], createdTicks[7], createdTicks[5], - createdTicks[3], createdTicks[2], createdTicks[0], ]; // Should exclude ticks that is within the same day except for the first. const expectedDailyTicks: PnlTicksFromDatabase[] = [ - createdTicks[8], createdTicks[7], - createdTicks[3], createdTicks[2], ]; @@ -486,6 +483,33 @@ describe('PnlTicks store', () => { } }); + it('Gets latest pnl ticks for subaccounts before or at given date', async () => { + const createdTicks: PnlTicksFromDatabase[] = await setupIntervalPnlTicks(); + const latestTicks: PnlTicksFromDatabase[] = await PnlTicksTable.getLatestPnlTick( + [defaultSubaccountId, defaultSubaccountIdWithAlternateAddress], + DateTime.fromISO(createdTicks[8].blockTime).plus({ seconds: 1 }), + ); + expect(latestTicks).toEqual([createdTicks[8], createdTicks[3]]); + }); + + it('Gets empty pnl ticks for subaccounts before or at date earlier than all pnl data', async () => { + const createdTicks: PnlTicksFromDatabase[] = await setupIntervalPnlTicks(); + const latestTicks: PnlTicksFromDatabase[] = await PnlTicksTable.getLatestPnlTick( + [defaultSubaccountId, defaultSubaccountIdWithAlternateAddress], + DateTime.fromISO(createdTicks[0].blockTime).minus({ years: 1 }), + ); + expect(latestTicks).toEqual([]); + }); + + it('Gets empty pnl ticks for subaccounts before or at date if no subaccounts given', async () => { + const createdTicks: PnlTicksFromDatabase[] = await setupIntervalPnlTicks(); + const latestTicks: PnlTicksFromDatabase[] = await PnlTicksTable.getLatestPnlTick( + [], + DateTime.fromISO(createdTicks[0].blockTime).plus({ years: 1 }), + ); + expect(latestTicks).toEqual([]); + }); + }); async function setupRankedPnlTicksData() { diff --git a/indexer/packages/postgres/src/stores/pnl-ticks-table.ts b/indexer/packages/postgres/src/stores/pnl-ticks-table.ts index 64ef22587a..30181a751e 100644 --- a/indexer/packages/postgres/src/stores/pnl-ticks-table.ts +++ b/indexer/packages/postgres/src/stores/pnl-ticks-table.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { DateTime } from 'luxon'; import { QueryBuilder } from 'objection'; import { @@ -465,7 +466,11 @@ export async function getPnlTicksAtIntervals( interval: PnlTickInterval, timeWindowSeconds: number, subaccountIds: string[], + earliestDate: DateTime, ): Promise { + if (subaccountIds.length === 0) { + return []; + } const result: { rows: PnlTicksFromDatabase[], } = await knexReadReplica.getConnection().raw( @@ -493,6 +498,7 @@ export async function getPnlTicksAtIntervals( FROM pnl_ticks WHERE "subaccountId" IN (${subaccountIds.map((id: string) => { return `'${id}'`; }).join(',')}) AND + "blockTime" >= '${earliestDate.toUTC().toISO()}'::timestamp AND "blockTime" > NOW() - INTERVAL '${timeWindowSeconds} second' ) AS pnl_intervals WHERE @@ -505,3 +511,40 @@ export async function getPnlTicksAtIntervals( return result.rows; } + +export async function getLatestPnlTick( + subaccountIds: string[], + beforeOrAt: DateTime, +): Promise { + if (subaccountIds.length === 0) { + return []; + } + const result: { + rows: PnlTicksFromDatabase[], + } = await knexReadReplica.getConnection().raw( + ` + SELECT + DISTINCT ON ("subaccountId") + "id", + "subaccountId", + "equity", + "totalPnl", + "netTransfers", + "createdAt", + "blockHeight", + "blockTime" + FROM + pnl_ticks + WHERE + "subaccountId" in (${subaccountIds.map((id: string) => { return `'${id}'`; }).join(',')}) AND + "blockTime" <= '${beforeOrAt.toUTC().toISO()}'::timestamp + ORDER BY + "subaccountId", + "blockTime" DESC + `, + ) as unknown as { + rows: PnlTicksFromDatabase[], + }; + + return result.rows; +} diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/vault-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/vault-controller.test.ts index 960e5c57b6..26304550fd 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/vault-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/vault-controller.test.ts @@ -43,6 +43,7 @@ describe('vault-controller#V4', () => { const mainVaultEquity: number = 10000; const vaultPnlHistoryHoursPrev: number = config.VAULT_PNL_HISTORY_HOURS; const vaultPnlLastPnlWindowPrev: number = config.VAULT_LATEST_PNL_TICK_WINDOW_HOURS; + const vaultPnlStartDatePrev: string = config.VAULT_PNL_START_DATE; beforeAll(async () => { await dbHelpers.migrate(); @@ -58,6 +59,8 @@ describe('vault-controller#V4', () => { config.VAULT_PNL_HISTORY_HOURS = 168; // Use last 48 hours to get latest pnl tick for tests. config.VAULT_LATEST_PNL_TICK_WINDOW_HOURS = 48; + // Use a time before all pnl ticks as the pnl start date. + config.VAULT_PNL_START_DATE = '2020-01-01T00:00:00Z'; await testMocks.seedData(); await perpetualMarketRefresher.updatePerpetualMarkets(); await liquidityTierRefresher.updateLiquidityTiers(); @@ -126,6 +129,7 @@ describe('vault-controller#V4', () => { await dbHelpers.clearData(); config.VAULT_PNL_HISTORY_HOURS = vaultPnlHistoryHoursPrev; config.VAULT_LATEST_PNL_TICK_WINDOW_HOURS = vaultPnlLastPnlWindowPrev; + config.VAULT_PNL_START_DATE = vaultPnlStartDatePrev; }); it('Get /megavault/historicalPnl with no vault subaccounts', async () => { @@ -138,21 +142,32 @@ describe('vault-controller#V4', () => { }); it.each([ - ['no resolution', '', [1, 2], 4], - ['daily resolution', '?resolution=day', [1, 2], 4], - ['hourly resolution', '?resolution=hour', [1, 2, 3, 4], 4], + ['no resolution', '', [1, 2], 4, undefined], + ['daily resolution', '?resolution=day', [1, 2], 4, undefined], + ['hourly resolution', '?resolution=hour', [1, 2, 3, 4], 4, undefined], + ['start date adjust PnL', '?resolution=hour', [1, 2, 3, 4], 4, twoDaysAgo.toISO()], ])('Get /megavault/historicalPnl with single vault subaccount (%s)', async ( _name: string, queryParam: string, expectedTicksIndex: number[], finalTickIndex: number, + startDate: string | undefined, ) => { + if (startDate !== undefined) { + config.VAULT_PNL_START_DATE = startDate; + } await VaultTable.create({ ...testConstants.defaultVault, address: testConstants.defaultSubaccount.address, clobPairId: testConstants.defaultPerpetualMarket.clobPairId, }); const createdPnlTicks: PnlTicksFromDatabase[] = await createPnlTicks(); + // Adjust PnL by total pnl of start date + if (startDate !== undefined) { + for (const createdPnlTick of createdPnlTicks) { + createdPnlTick.totalPnl = Big(createdPnlTick.totalPnl).sub('10000').toFixed(); + } + } const finalTick: PnlTicksFromDatabase = { ...createdPnlTicks[finalTickIndex], equity: Big(vault1Equity).toFixed(), diff --git a/indexer/services/comlink/src/config.ts b/indexer/services/comlink/src/config.ts index bfb702abcc..c6f942b046 100644 --- a/indexer/services/comlink/src/config.ts +++ b/indexer/services/comlink/src/config.ts @@ -62,8 +62,10 @@ export const configSchema = { // Vaults config VAULT_PNL_HISTORY_DAYS: parseInteger({ default: 90 }), VAULT_PNL_HISTORY_HOURS: parseInteger({ default: 72 }), + VAULT_PNL_START_DATE: parseString({ default: '2024-01-01T00:00:00Z' }), VAULT_LATEST_PNL_TICK_WINDOW_HOURS: parseInteger({ default: 1 }), VAULT_FETCH_FUNDING_INDEX_BLOCK_WINDOWS: parseInteger({ default: 250_000 }), + }; //////////////////////////////////////////////////////////////////////////////// diff --git a/indexer/services/comlink/src/controllers/api/v4/vault-controller.ts b/indexer/services/comlink/src/controllers/api/v4/vault-controller.ts index a249b50b23..dbc4043ab8 100644 --- a/indexer/services/comlink/src/controllers/api/v4/vault-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/vault-controller.ts @@ -101,7 +101,7 @@ class VaultController extends Controller { BlockFromDatabase, string, PnlTicksFromDatabase | undefined, - DateTime | undefined + DateTime | undefined, ] = await Promise.all([ getVaultSubaccountPnlTicks(vaultSubaccountIdsWithMainSubaccount, getResolution(resolution)), getVaultPositions(vaultSubaccounts), @@ -114,7 +114,6 @@ class VaultController extends Controller { `${config.SERVICE_NAME}.${controllerName}.fetch_ticks_positions_equity.timing`, Date.now() - startTicksPositions, ); - // aggregate pnlTicks for all vault subaccounts grouped by blockHeight const aggregatedPnlTicks: PnlTicksFromDatabase[] = aggregateVaultPnlTicks( vaultPnlTicks, @@ -337,13 +336,28 @@ async function getVaultSubaccountPnlTicks( windowSeconds = config.VAULT_PNL_HISTORY_HOURS * 60 * 60; // hours to seconds } - const pnlTicks: PnlTicksFromDatabase[] = await PnlTicksTable.getPnlTicksAtIntervals( - resolution, - windowSeconds, - vaultSubaccountIds, - ); + const [ + pnlTicks, + adjustByPnlTicks, + ] : [ + PnlTicksFromDatabase[], + PnlTicksFromDatabase[], + ] = await Promise.all([ + PnlTicksTable.getPnlTicksAtIntervals( + resolution, + windowSeconds, + vaultSubaccountIds, + getVautlPnlStartDate(), + ), + PnlTicksTable.getLatestPnlTick( + vaultSubaccountIds, + // Add a buffer of 10 minutes to get the first PnL tick for PnL data as PnL ticks aren't + // created exactly on the hour. + getVautlPnlStartDate().plus({ minutes: 10 }), + ), + ]); - return pnlTicks; + return adjustVaultPnlTicks(pnlTicks, adjustByPnlTicks); } async function getVaultPositions( @@ -538,14 +552,33 @@ export async function getLatestPnlTick( vaultSubaccountIds: string[], vaults: VaultFromDatabase[], ): Promise { - const pnlTicks: PnlTicksFromDatabase[] = await PnlTicksTable.getPnlTicksAtIntervals( - PnlTickInterval.hour, - config.VAULT_LATEST_PNL_TICK_WINDOW_HOURS * 60 * 60, - vaultSubaccountIds, + const [ + pnlTicks, + adjustByPnlTicks, + ] : [ + PnlTicksFromDatabase[], + PnlTicksFromDatabase[], + ] = await Promise.all([ + PnlTicksTable.getPnlTicksAtIntervals( + PnlTickInterval.hour, + config.VAULT_LATEST_PNL_TICK_WINDOW_HOURS * 60 * 60, + vaultSubaccountIds, + getVautlPnlStartDate(), + ), + PnlTicksTable.getLatestPnlTick( + vaultSubaccountIds, + // Add a buffer of 10 minutes to get the first PnL tick for PnL data as PnL ticks aren't + // created exactly on the hour. + getVautlPnlStartDate().plus({ minutes: 10 }), + ), + ]); + const adjustedPnlTicks: PnlTicksFromDatabase[] = adjustVaultPnlTicks( + pnlTicks, + adjustByPnlTicks, ); // Aggregate and get pnl tick closest to the hour const aggregatedTicks: PnlTicksFromDatabase[] = aggregateVaultPnlTicks( - pnlTicks, + adjustedPnlTicks, vaults, ); const filteredTicks: PnlTicksFromDatabase[] = filterOutIntervalTicks( @@ -708,6 +741,29 @@ function aggregateVaultPnlTicks( }).map((aggregatedPnlTick: AggregatedPnlTick) => { return aggregatedPnlTick.pnlTick; }); } +function adjustVaultPnlTicks( + pnlTicks: PnlTicksFromDatabase[], + pnlTicksToAdjustBy: PnlTicksFromDatabase[], +): PnlTicksFromDatabase[] { + const subaccountToPnlTick: {[subaccountId: string]: PnlTicksFromDatabase} = {}; + for (const pnlTickToAdjustBy of pnlTicksToAdjustBy) { + subaccountToPnlTick[pnlTickToAdjustBy.subaccountId] = pnlTickToAdjustBy; + } + + return pnlTicks.map((pnlTick: PnlTicksFromDatabase): PnlTicksFromDatabase => { + const adjustByPnlTick: PnlTicksFromDatabase | undefined = subaccountToPnlTick[ + pnlTick.subaccountId + ]; + if (adjustByPnlTick === undefined) { + return pnlTick; + } + return { + ...pnlTick, + totalPnl: Big(pnlTick.totalPnl).sub(Big(adjustByPnlTick.totalPnl)).toFixed(), + }; + }); +} + async function getVaultMapping(): Promise { const vaults: VaultFromDatabase[] = await VaultTable.findAll( {}, @@ -740,4 +796,9 @@ async function getVaultMapping(): Promise { return validVaultMapping; } +function getVautlPnlStartDate(): DateTime { + const startDate: DateTime = DateTime.fromISO(config.VAULT_PNL_START_DATE).toUTC(); + return startDate; +} + export default router;