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 c0a9fe6c6e..ff03a401f6 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 @@ -18,17 +18,22 @@ import request from 'supertest'; import { getFixedRepresentation, sendRequest } from '../../../helpers/helpers'; import config from '../../../../src/config'; import { DateTime } from 'luxon'; +import Big from 'big.js'; describe('vault-controller#V4', () => { const experimentVaultsPrevVal: string = config.EXPERIMENT_VAULTS; const experimentVaultMarketsPrevVal: string = config.EXPERIMENT_VAULT_MARKETS; + const latestBlockHeight: string = '25'; const currentBlockHeight: string = '7'; const twoHourBlockHeight: string = '5'; const twoDayBlockHeight: string = '3'; const currentTime: DateTime = DateTime.utc().startOf('day').minus({ hour: 5 }); + const latestTime: DateTime = currentTime.plus({ second: 5 }); const twoHoursAgo: DateTime = currentTime.minus({ hour: 2 }); const twoDaysAgo: DateTime = currentTime.minus({ day: 2 }); const initialFundingIndex: string = '10000'; + const vault1Equity: number = 159500; + const vault2Equity: number = 10000; beforeAll(async () => { await dbHelpers.migrate(); @@ -61,8 +66,33 @@ describe('vault-controller#V4', () => { time: currentTime.toISO(), blockHeight: currentBlockHeight, }), + BlockTable.create({ + ...testConstants.defaultBlock, + time: latestTime.toISO(), + blockHeight: latestBlockHeight, + }), ]); await SubaccountTable.create(testConstants.vaultSubaccount); + await Promise.all([ + PerpetualPositionTable.create( + testConstants.defaultPerpetualPosition, + ), + AssetPositionTable.upsert(testConstants.defaultAssetPosition), + AssetPositionTable.upsert({ + ...testConstants.defaultAssetPosition, + subaccountId: testConstants.vaultSubaccountId, + }), + FundingIndexUpdatesTable.create({ + ...testConstants.defaultFundingIndexUpdate, + fundingIndex: initialFundingIndex, + effectiveAtHeight: testConstants.createdHeight, + }), + FundingIndexUpdatesTable.create({ + ...testConstants.defaultFundingIndexUpdate, + eventId: testConstants.defaultTendermintEventId2, + effectiveAtHeight: twoDayBlockHeight, + }), + ]); }); afterEach(async () => { @@ -93,17 +123,25 @@ describe('vault-controller#V4', () => { expectedTicksIndex: number[], ) => { const createdPnlTicks: PnlTicksFromDatabase[] = await createPnlTicks(); + const finalTick: PnlTicksFromDatabase = { + ...createdPnlTicks[expectedTicksIndex[expectedTicksIndex.length - 1]], + equity: Big(vault1Equity).toFixed(), + blockHeight: latestBlockHeight, + blockTime: latestTime.toISO(), + createdAt: latestTime.toISO(), + }; const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/vault/v1/megavault/historicalPnl${queryParam}`, }); + expect(response.body.megavaultPnl).toHaveLength(expectedTicksIndex.length + 1); expect(response.body.megavaultPnl).toEqual( expect.arrayContaining( expectedTicksIndex.map((index: number) => { return expect.objectContaining(createdPnlTicks[index]); - }), + }).concat([finalTick]), ), ); }); @@ -127,7 +165,6 @@ describe('vault-controller#V4', () => { ].join(','); const createdPnlTicks: PnlTicksFromDatabase[] = await createPnlTicks(); - const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/vault/v1/megavault/historicalPnl${queryParam}`, @@ -138,7 +175,15 @@ describe('vault-controller#V4', () => { totalPnl: (parseFloat(testConstants.defaultPnlTick.totalPnl) * 2).toString(), netTransfers: (parseFloat(testConstants.defaultPnlTick.netTransfers) * 2).toString(), }; + const finalTick: PnlTicksFromDatabase = { + ...expectedPnlTickBase, + equity: Big(vault1Equity).add(vault2Equity).toFixed(), + blockHeight: latestBlockHeight, + blockTime: latestTime.toISO(), + createdAt: latestTime.toISO(), + }; + expect(response.body.megavaultPnl).toHaveLength(expectedTicksIndex.length + 1); expect(response.body.megavaultPnl).toEqual( expect.arrayContaining( expectedTicksIndex.map((index: number) => { @@ -148,7 +193,7 @@ describe('vault-controller#V4', () => { blockHeight: createdPnlTicks[index].blockHeight, blockTime: createdPnlTicks[index].blockTime, }); - }), + }).concat([expect.objectContaining(finalTick)]), ), ); }); @@ -175,6 +220,13 @@ describe('vault-controller#V4', () => { expectedTicksIndex: number[], ) => { const createdPnlTicks: PnlTicksFromDatabase[] = await createPnlTicks(); + const finalTick: PnlTicksFromDatabase = { + ...createdPnlTicks[expectedTicksIndex[expectedTicksIndex.length - 1]], + equity: Big(vault1Equity).toFixed(), + blockHeight: latestBlockHeight, + blockTime: latestTime.toISO(), + createdAt: latestTime.toISO(), + }; const response: request.Response = await sendRequest({ type: RequestMethod.GET, @@ -182,13 +234,13 @@ describe('vault-controller#V4', () => { }); expect(response.body.vaultsPnl).toHaveLength(1); - + expect(response.body.vaultsPnl[0].historicalPnl).toHaveLength(expectedTicksIndex.length + 1); expect(response.body.vaultsPnl[0]).toEqual({ ticker: testConstants.defaultPerpetualMarket.ticker, historicalPnl: expect.arrayContaining( expectedTicksIndex.map((index: number) => { return expect.objectContaining(createdPnlTicks[index]); - }), + }).concat(finalTick), ), }); }); @@ -213,6 +265,20 @@ describe('vault-controller#V4', () => { ].join(','); const createdPnlTicks: PnlTicksFromDatabase[] = await createPnlTicks(); + const finalTick1: PnlTicksFromDatabase = { + ...createdPnlTicks[expectedTicksIndex1[expectedTicksIndex1.length - 1]], + equity: Big(vault1Equity).toFixed(), + blockHeight: latestBlockHeight, + blockTime: latestTime.toISO(), + createdAt: latestTime.toISO(), + }; + const finalTick2: PnlTicksFromDatabase = { + ...createdPnlTicks[expectedTicksIndex2[expectedTicksIndex2.length - 1]], + equity: Big(vault2Equity).toFixed(), + blockHeight: latestBlockHeight, + blockTime: latestTime.toISO(), + createdAt: latestTime.toISO(), + }; const response: request.Response = await sendRequest({ type: RequestMethod.GET, @@ -223,14 +289,14 @@ describe('vault-controller#V4', () => { ticker: testConstants.defaultPerpetualMarket.ticker, historicalPnl: expectedTicksIndex1.map((index: number) => { return createdPnlTicks[index]; - }), + }).concat(finalTick1), }; const expectedVaultPnl2: VaultHistoricalPnl = { ticker: testConstants.defaultPerpetualMarket2.ticker, historicalPnl: expectedTicksIndex2.map((index: number) => { return createdPnlTicks[index]; - }), + }).concat(finalTick2), }; expect(response.body.vaultsPnl).toEqual( @@ -260,23 +326,6 @@ describe('vault-controller#V4', () => { }); it('Get /megavault/positions with 1 vault subaccount', async () => { - await Promise.all([ - PerpetualPositionTable.create( - testConstants.defaultPerpetualPosition, - ), - AssetPositionTable.upsert(testConstants.defaultAssetPosition), - FundingIndexUpdatesTable.create({ - ...testConstants.defaultFundingIndexUpdate, - fundingIndex: initialFundingIndex, - effectiveAtHeight: testConstants.createdHeight, - }), - FundingIndexUpdatesTable.create({ - ...testConstants.defaultFundingIndexUpdate, - eventId: testConstants.defaultTendermintEventId2, - effectiveAtHeight: twoDayBlockHeight, - }), - ]); - const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: '/v4/vault/v1/megavault/positions', @@ -334,27 +383,6 @@ describe('vault-controller#V4', () => { testConstants.defaultPerpetualMarket2.clobPairId, ].join(','); - await Promise.all([ - PerpetualPositionTable.create( - testConstants.defaultPerpetualPosition, - ), - AssetPositionTable.upsert(testConstants.defaultAssetPosition), - AssetPositionTable.upsert({ - ...testConstants.defaultAssetPosition, - subaccountId: testConstants.vaultSubaccountId, - }), - FundingIndexUpdatesTable.create({ - ...testConstants.defaultFundingIndexUpdate, - fundingIndex: initialFundingIndex, - effectiveAtHeight: testConstants.createdHeight, - }), - FundingIndexUpdatesTable.create({ - ...testConstants.defaultFundingIndexUpdate, - eventId: testConstants.defaultTendermintEventId2, - effectiveAtHeight: twoDayBlockHeight, - }), - ]); - const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: '/v4/vault/v1/megavault/positions', 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 0403242ca4..830980955d 100644 --- a/indexer/services/comlink/src/controllers/api/v4/vault-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/vault-controller.ts @@ -23,6 +23,7 @@ import { FundingIndexUpdatesTable, PnlTickInterval, } from '@dydxprotocol-indexer/postgres'; +import Big from 'big.js'; import express from 'express'; import { checkSchema, matchedData } from 'express-validator'; import _ from 'lodash'; @@ -68,13 +69,38 @@ class VaultController extends Controller { async getMegavaultHistoricalPnl( @Query() resolution?: PnlTickInterval, ): Promise { - const vaultPnlTicks: PnlTicksFromDatabase[] = await getVaultSubaccountPnlTicks(resolution); + const vaultSubaccounts: VaultMapping = getVaultSubaccountsFromConfig(); + const [ + vaultPnlTicks, + vaultPositions, + latestBlock, + ] : [ + PnlTicksFromDatabase[], + Map, + BlockFromDatabase, + ] = await Promise.all([ + getVaultSubaccountPnlTicks(resolution), + getVaultPositions(vaultSubaccounts), + BlockTable.getLatest(), + ]); // aggregate pnlTicks for all vault subaccounts grouped by blockHeight const aggregatedPnlTicks: Map = aggregatePnlTicks(vaultPnlTicks); + const currentEquity: string = Array.from(vaultPositions.values()) + .map((position: VaultPosition): string => { + return position.equity; + }).reduce((acc: string, curr: string): string => { + return (Big(acc).add(Big(curr))).toFixed(); + }, '0'); + const pnlTicksWithCurrentTick: PnlTicksFromDatabase[] = getPnlTicksWithCurrentTick( + currentEquity, + Array.from(aggregatedPnlTicks.values()), + latestBlock, + ); + return { - megavaultPnl: Array.from(aggregatedPnlTicks.values()).map( + megavaultPnl: pnlTicksWithCurrentTick.map( (pnlTick: PnlTicksFromDatabase) => { return pnlTicksToResponseObject(pnlTick); }), @@ -86,7 +112,19 @@ class VaultController extends Controller { @Query() resolution?: PnlTickInterval, ): Promise { const vaultSubaccounts: VaultMapping = getVaultSubaccountsFromConfig(); - const vaultPnlTicks: PnlTicksFromDatabase[] = await getVaultSubaccountPnlTicks(resolution); + const [ + vaultPnlTicks, + vaultPositions, + latestBlock, + ] : [ + PnlTicksFromDatabase[], + Map, + BlockFromDatabase, + ] = await Promise.all([ + getVaultSubaccountPnlTicks(resolution), + getVaultPositions(vaultSubaccounts), + BlockTable.getLatest(), + ]); const groupedVaultPnlTicks: VaultHistoricalPnl[] = _(vaultPnlTicks) .groupBy('subaccountId') @@ -102,9 +140,17 @@ class VaultController extends Controller { 'a perpetual market.'); } + const vaultPosition: VaultPosition | undefined = vaultPositions.get(subaccountId); + const currentEquity: string = vaultPosition === undefined ? '0' : vaultPosition.equity; + const pnlTicksWithCurrentTick: PnlTicksFromDatabase[] = getPnlTicksWithCurrentTick( + currentEquity, + pnlTicks, + latestBlock, + ); + return { ticker: market.ticker, - historicalPnl: pnlTicks, + historicalPnl: pnlTicksWithCurrentTick, }; }) .values() @@ -118,120 +164,11 @@ class VaultController extends Controller { @Get('/megavault/positions') async getMegavaultPositions(): Promise { const vaultSubaccounts: VaultMapping = getVaultSubaccountsFromConfig(); - const vaultSubaccountIds: string[] = _.keys(vaultSubaccounts); - if (vaultSubaccountIds.length === 0) { - return { - positions: [], - }; - } - - const [ - subaccounts, - assets, - openPerpetualPositions, - assetPositions, - markets, - latestBlock, - ]: [ - SubaccountFromDatabase[], - AssetFromDatabase[], - PerpetualPositionFromDatabase[], - AssetPositionFromDatabase[], - MarketFromDatabase[], - BlockFromDatabase | undefined, - ] = await Promise.all([ - SubaccountTable.findAll( - { - id: vaultSubaccountIds, - }, - [], - ), - AssetTable.findAll( - {}, - [], - ), - PerpetualPositionTable.findAll( - { - subaccountId: vaultSubaccountIds, - status: [PerpetualPositionStatus.OPEN], - }, - [], - ), - AssetPositionTable.findAll( - { - subaccountId: vaultSubaccountIds, - assetId: [USDC_ASSET_ID], - }, - [], - ), - MarketTable.findAll( - {}, - [], - ), - BlockTable.getLatest(), - ]); - - const latestFundingIndexMap: FundingIndexMap = await FundingIndexUpdatesTable - .findFundingIndexMap( - latestBlock.blockHeight, - ); - const assetPositionsBySubaccount: - { [subaccountId: string]: AssetPositionFromDatabase[] } = _.groupBy( - assetPositions, - 'subaccountId', - ); - const openPerpetualPositionsBySubaccount: - { [subaccountId: string]: PerpetualPositionFromDatabase[] } = _.groupBy( - openPerpetualPositions, - 'subaccountId', - ); - const assetIdToAsset: AssetById = _.keyBy( - assets, - AssetColumns.id, - ); - - const vaultPositions: VaultPosition[] = await Promise.all( - subaccounts.map(async (subaccount: SubaccountFromDatabase) => { - const perpetualMarket: PerpetualMarketFromDatabase | undefined = perpetualMarketRefresher - .getPerpetualMarketFromClobPairId(vaultSubaccounts[subaccount.id]); - if (perpetualMarket === undefined) { - throw new Error( - `Vault clob pair id ${vaultSubaccounts[subaccount.id]} does not correspond to a ` + - 'perpetual market.'); - } - const lastUpdatedFundingIndexMap: FundingIndexMap = await FundingIndexUpdatesTable - .findFundingIndexMap( - subaccount.updatedAtHeight, - ); - - const subaccountResponse: SubaccountResponseObject = getSubaccountResponse( - subaccount, - openPerpetualPositionsBySubaccount[subaccount.id] || [], - assetPositionsBySubaccount[subaccount.id] || [], - assets, - markets, - perpetualMarketRefresher.getPerpetualMarketsMap(), - latestBlock.blockHeight, - latestFundingIndexMap, - lastUpdatedFundingIndexMap, - ); - - return { - ticker: perpetualMarket.ticker, - assetPosition: subaccountResponse.assetPositions[ - assetIdToAsset[USDC_ASSET_ID].symbol - ], - perpetualPosition: subaccountResponse.openPerpetualPositions[ - perpetualMarket.ticker - ] || undefined, - equity: subaccountResponse.equity, - }; - }), - ); + const vaultPositions: Map = await getVaultPositions(vaultSubaccounts); return { - positions: _.sortBy(vaultPositions, 'ticker'), + positions: _.sortBy(Array.from(vaultPositions.values()), 'ticker'), }; } } @@ -371,6 +308,152 @@ async function getVaultSubaccountPnlTicks( return pnlTicks; } +async function getVaultPositions( + vaultSubaccounts: VaultMapping, +): Promise> { + const vaultSubaccountIds: string[] = _.keys(vaultSubaccounts); + if (vaultSubaccountIds.length === 0) { + return new Map(); + } + + const [ + subaccounts, + assets, + openPerpetualPositions, + assetPositions, + markets, + latestBlock, + ]: [ + SubaccountFromDatabase[], + AssetFromDatabase[], + PerpetualPositionFromDatabase[], + AssetPositionFromDatabase[], + MarketFromDatabase[], + BlockFromDatabase | undefined, + ] = await Promise.all([ + SubaccountTable.findAll( + { + id: vaultSubaccountIds, + }, + [], + ), + AssetTable.findAll( + {}, + [], + ), + PerpetualPositionTable.findAll( + { + subaccountId: vaultSubaccountIds, + status: [PerpetualPositionStatus.OPEN], + }, + [], + ), + AssetPositionTable.findAll( + { + subaccountId: vaultSubaccountIds, + assetId: [USDC_ASSET_ID], + }, + [], + ), + MarketTable.findAll( + {}, + [], + ), + BlockTable.getLatest(), + ]); + + const latestFundingIndexMap: FundingIndexMap = await FundingIndexUpdatesTable + .findFundingIndexMap( + latestBlock.blockHeight, + ); + const assetPositionsBySubaccount: + { [subaccountId: string]: AssetPositionFromDatabase[] } = _.groupBy( + assetPositions, + 'subaccountId', + ); + const openPerpetualPositionsBySubaccount: + { [subaccountId: string]: PerpetualPositionFromDatabase[] } = _.groupBy( + openPerpetualPositions, + 'subaccountId', + ); + const assetIdToAsset: AssetById = _.keyBy( + assets, + AssetColumns.id, + ); + + const vaultPositionsAndSubaccountId: { + position: VaultPosition, + subaccountId: string, + }[] = await Promise.all( + subaccounts.map(async (subaccount: SubaccountFromDatabase) => { + const perpetualMarket: PerpetualMarketFromDatabase | undefined = perpetualMarketRefresher + .getPerpetualMarketFromClobPairId(vaultSubaccounts[subaccount.id]); + if (perpetualMarket === undefined) { + throw new Error( + `Vault clob pair id ${vaultSubaccounts[subaccount.id]} does not correspond to a ` + + 'perpetual market.'); + } + const lastUpdatedFundingIndexMap: FundingIndexMap = await FundingIndexUpdatesTable + .findFundingIndexMap( + subaccount.updatedAtHeight, + ); + + const subaccountResponse: SubaccountResponseObject = getSubaccountResponse( + subaccount, + openPerpetualPositionsBySubaccount[subaccount.id] || [], + assetPositionsBySubaccount[subaccount.id] || [], + assets, + markets, + perpetualMarketRefresher.getPerpetualMarketsMap(), + latestBlock.blockHeight, + latestFundingIndexMap, + lastUpdatedFundingIndexMap, + ); + + return { + position: { + ticker: perpetualMarket.ticker, + assetPosition: subaccountResponse.assetPositions[ + assetIdToAsset[USDC_ASSET_ID].symbol + ], + perpetualPosition: subaccountResponse.openPerpetualPositions[ + perpetualMarket.ticker + ] || undefined, + equity: subaccountResponse.equity, + }, + subaccountId: subaccount.id, + }; + }), + ); + + return new Map(vaultPositionsAndSubaccountId.map( + (obj: { position: VaultPosition, subaccountId: string }) : [string, VaultPosition] => { + return [ + obj.subaccountId, + obj.position, + ]; + }, + )); +} + +function getPnlTicksWithCurrentTick( + equity: string, + pnlTicks: PnlTicksFromDatabase[], + latestBlock: BlockFromDatabase, +): PnlTicksFromDatabase[] { + if (pnlTicks.length === 0) { + return []; + } + const currentTick: PnlTicksFromDatabase = { + ...pnlTicks[pnlTicks.length - 1], + equity, + blockHeight: latestBlock.blockHeight, + blockTime: latestBlock.time, + createdAt: latestBlock.time, + }; + return pnlTicks.concat([currentTick]); +} + // TODO(TRA-570): Placeholder for getting vault subaccount ids until vault table is added. function getVaultSubaccountsFromConfig(): VaultMapping { if (config.EXPERIMENT_VAULTS === '' && config.EXPERIMENT_VAULT_MARKETS === '') {