diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index f6fe55c99d..5ff0a194eb 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -468,7 +468,7 @@ export const isolatedPerpetualPosition: PerpetualPositionCreateObject = { status: PerpetualPositionStatus.OPEN, size: '10', maxSize: '25', - entryPrice: '20000', + entryPrice: '1.5', sumOpen: '10', sumClose: '0', createdAt: createdDateTime.toISO(), @@ -643,7 +643,7 @@ export const isolatedMarket: MarketCreateObject = { pair: 'ISO-USD', exponent: -12, minPriceChangePpm: 50, - oraclePrice: '0.000000075', + oraclePrice: '1.00', }; export const isolatedMarket2: MarketCreateObject = { diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts index c60f47b979..112ade7bb7 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts @@ -1,13 +1,13 @@ import { + BlockTable, dbHelpers, - testMocks, - testConstants, + FundingIndexUpdatesTable, perpetualMarketRefresher, + PerpetualPositionStatus, PerpetualPositionTable, PositionSide, - BlockTable, - FundingIndexUpdatesTable, - PerpetualPositionStatus, + testConstants, + testMocks, } from '@dydxprotocol-indexer/postgres'; import { PerpetualPositionResponseObject, RequestMethod } from '../../../../src/types'; import request from 'supertest'; @@ -245,5 +245,241 @@ describe('perpetual-positions-controller#V4', () => { ]), })); }); + + it('Get /perpetualPositions/parentSubaccountNumber gets long/short positions across subaccounts', async () => { + await Promise.all([ + PerpetualPositionTable.create(testConstants.defaultPerpetualPosition), + PerpetualPositionTable.create({ + ...testConstants.isolatedPerpetualPosition, + side: PositionSide.SHORT, + size: '-10', + }), + ]); + await Promise.all([ + FundingIndexUpdatesTable.create({ + ...testConstants.isolatedMarketFundingIndexUpdate, + fundingIndex: '10000', + effectiveAtHeight: testConstants.createdHeight, + }), + FundingIndexUpdatesTable.create({ + ...testConstants.isolatedMarketFundingIndexUpdate, + eventId: testConstants.defaultTendermintEventId2, + effectiveAtHeight: latestHeight, + }), + ]); + + const parentSubaccountNumber: number = 0; + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/perpetualPositions/parentSubaccountNumber?address=${testConstants.defaultAddress}` + + `&parentSubaccountNumber=${parentSubaccountNumber}`, + }); + + const expected: PerpetualPositionResponseObject = { + market: testConstants.defaultPerpetualMarket.ticker, + side: testConstants.defaultPerpetualPosition.side, + status: testConstants.defaultPerpetualPosition.status, + size: testConstants.defaultPerpetualPosition.size, + maxSize: testConstants.defaultPerpetualPosition.maxSize, + entryPrice: getFixedRepresentation(testConstants.defaultPerpetualPosition.entryPrice!), + exitPrice: null, + sumOpen: testConstants.defaultPerpetualPosition.sumOpen!, + sumClose: testConstants.defaultPerpetualPosition.sumClose!, + // For the calculation of the net funding (long position): + // settled funding on position = 200_000, size = 10, latest funding index = 10050 + // last updated funding index = 10000 + // total funding = 200_000 + (10 * (10000 - 10050)) = 199_500 + netFunding: getFixedRepresentation('199500'), + // sumClose=0, so realized Pnl is the same as the net funding of the position. + // Unsettled funding is funding payments that already "happened" but not reflected + // in the subaccount's balance yet, so it's considered a part of realizedPnl. + realizedPnl: getFixedRepresentation('199500'), + // For the calculation of the unrealized pnl (long position): + // index price = 15_000, entry price = 20_000, size = 10 + // unrealizedPnl = size * (index price - entry price) + // unrealizedPnl = 10 * (15_000 - 20_000) + unrealizedPnl: getFixedRepresentation('-50000'), + createdAt: testConstants.createdDateTime.toISO(), + closedAt: null, + createdAtHeight: testConstants.createdHeight, + subaccountNumber: testConstants.defaultSubaccount.subaccountNumber, + }; + // object for expected 2 which holds an isolated position in an isolated perpetual + // in the isolated subaccount + const expected2: PerpetualPositionResponseObject = { + market: testConstants.isolatedPerpetualMarket.ticker, + side: PositionSide.SHORT, + status: testConstants.isolatedPerpetualPosition.status, + size: '-10', + maxSize: testConstants.isolatedPerpetualPosition.maxSize, + entryPrice: getFixedRepresentation(testConstants.isolatedPerpetualPosition.entryPrice!), + exitPrice: null, + sumOpen: testConstants.isolatedPerpetualPosition.sumOpen!, + sumClose: testConstants.isolatedPerpetualPosition.sumClose!, + // For the calculation of the net funding (short position): + // settled funding on position = 200_000, size = -10, latest funding index = 10200 + // last updated funding index = 10000 + // total funding = 200_000 + (-10 * (10000 - 10200)) = 202_000 + netFunding: getFixedRepresentation('202000'), + // sumClose=0, so realized Pnl is the same as the net funding of the position. + // Unsettled funding is funding payments that already "happened" but not reflected + // in the subaccount's balance yet, so it's considered a part of realizedPnl. + realizedPnl: getFixedRepresentation('202000'), + // For the calculation of the unrealized pnl (short position): + // index price = 1, entry price = 1.5, size = -10 + // unrealizedPnl = size * (index price - entry price) + // unrealizedPnl = -10 * (1-1.5) + unrealizedPnl: getFixedRepresentation('5'), + createdAt: testConstants.createdDateTime.toISO(), + closedAt: null, + createdAtHeight: testConstants.createdHeight, + subaccountNumber: testConstants.isolatedSubaccount.subaccountNumber, + }; + + expect(response.body.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...expected, + }), + expect.objectContaining({ + ...expected2, + }), + ]), + ); + }); + + it('Get /perpetualPositions/parentSubaccountNumber gets CLOSED position without adjusting funding', async () => { + await Promise.all([ + PerpetualPositionTable.create({ + ...testConstants.defaultPerpetualPosition, + status: PerpetualPositionStatus.CLOSED, + }), + PerpetualPositionTable.create({ + ...testConstants.isolatedPerpetualPosition, + side: PositionSide.SHORT, + size: '-10', + }), + ]); + + const parentSubaccountNumber: number = 0; + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/perpetualPositions/parentSubaccountNumber?address=${testConstants.defaultAddress}` + + `&parentSubaccountNumber=${parentSubaccountNumber}`, + }); + + const expected: PerpetualPositionResponseObject = { + market: testConstants.defaultPerpetualMarket.ticker, + side: testConstants.defaultPerpetualPosition.side, + status: PerpetualPositionStatus.CLOSED, + size: testConstants.defaultPerpetualPosition.size, + maxSize: testConstants.defaultPerpetualPosition.maxSize, + entryPrice: getFixedRepresentation(testConstants.defaultPerpetualPosition.entryPrice!), + exitPrice: null, + sumOpen: testConstants.defaultPerpetualPosition.sumOpen!, + sumClose: testConstants.defaultPerpetualPosition.sumClose!, + // CLOSED position should not have funding adjusted + netFunding: getFixedRepresentation( + testConstants.defaultPerpetualPosition.settledFunding, + ), + realizedPnl: getFixedRepresentation( + testConstants.defaultPerpetualPosition.settledFunding, + ), + // For the calculation of the unrealized pnl (short position): + // index price = 15_000, entry price = 20_000, size = 10 + // unrealizedPnl = size * (index price - entry price) + // unrealizedPnl = 10 * (15_000 - 20_000) + unrealizedPnl: getFixedRepresentation('-50000'), + createdAt: testConstants.createdDateTime.toISO(), + closedAt: null, + createdAtHeight: testConstants.createdHeight, + subaccountNumber: testConstants.defaultSubaccount.subaccountNumber, + }; + const expected2: PerpetualPositionResponseObject = { + market: testConstants.isolatedPerpetualMarket.ticker, + side: PositionSide.SHORT, + status: testConstants.isolatedPerpetualPosition.status, + size: '-10', + maxSize: testConstants.isolatedPerpetualPosition.maxSize, + entryPrice: getFixedRepresentation(testConstants.isolatedPerpetualPosition.entryPrice!), + exitPrice: null, + sumOpen: testConstants.isolatedPerpetualPosition.sumOpen!, + sumClose: testConstants.isolatedPerpetualPosition.sumClose!, + // CLOSED position should not have funding adjusted + netFunding: getFixedRepresentation( + testConstants.isolatedPerpetualPosition.settledFunding, + ), + realizedPnl: getFixedRepresentation( + testConstants.isolatedPerpetualPosition.settledFunding, + ), + // For the calculation of the unrealized pnl (short position): + // index price = 1, entry price = 1.5, size = -10 + // unrealizedPnl = size * (index price - entry price) + // unrealizedPnl = -10 * (1-1.5) + unrealizedPnl: getFixedRepresentation('5'), + createdAt: testConstants.createdDateTime.toISO(), + closedAt: null, + createdAtHeight: testConstants.createdHeight, + subaccountNumber: testConstants.isolatedSubaccount.subaccountNumber, + }; + + expect(response.body.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...expected, + }), + expect.objectContaining({ + ...expected2, + }), + ]), + ); + }); + + it.each([ + [ + 'invalid status', + { + address: defaultAddress, + parentSubaccountNumber: defaultSubaccountNumber, + status: 'INVALID', + }, + 'status', + 'status must be a valid Position Status (OPEN, etc)', + ], + [ + 'multiple invalid status', + { + address: defaultAddress, + parentSubaccountNumber: defaultSubaccountNumber, + status: 'INVALID,INVALID', + }, + 'status', + 'status must be a valid Position Status (OPEN, etc)', + ], + ])('Returns 400 when validation fails: %s', async ( + _reason: string, + queryParams: { + address?: string, + subaccountNumber?: number, + status?: string, + }, + fieldWithError: string, + expectedErrorMsg: string, + ) => { + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/perpetualPositions/parentSubaccountNumber?${getQueryString(queryParams)}`, + expectedStatus: 400, + }); + + expect(response.body).toEqual(expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + param: fieldWithError, + msg: expectedErrorMsg, + }), + ]), + })); + }); }); }); diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index 2fd059f3b0..8c5bd6f4ce 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -1863,6 +1863,106 @@ fetch('https://dydx-testnet.imperator.co/v4/perpetualPositions?address=string&su This operation does not require authentication +## ListPositionsForParentSubaccount + + + +> Code samples + +```python +import requests +headers = { + 'Accept': 'application/json' +} + +r = requests.get('https://dydx-testnet.imperator.co/v4/perpetualPositions/parentSubaccountNumber', params={ + 'address': 'string', 'parentSubaccountNumber': '0' +}, headers = headers) + +print(r.json()) + +``` + +```javascript + +const headers = { + 'Accept':'application/json' +}; + +fetch('https://dydx-testnet.imperator.co/v4/perpetualPositions/parentSubaccountNumber?address=string&parentSubaccountNumber=0', +{ + method: 'GET', + + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`GET /perpetualPositions/parentSubaccountNumber` + +### Parameters + +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|address|query|string|true|none| +|parentSubaccountNumber|query|number(double)|true|none| +|status|query|array[string]|false|none| +|limit|query|number(double)|false|none| +|createdBeforeOrAtHeight|query|number(double)|false|none| +|createdBeforeOrAt|query|[IsoString](#schemaisostring)|false|none| + +#### Enumerated Values + +|Parameter|Value| +|---|---| +|status|OPEN| +|status|CLOSED| +|status|LIQUIDATED| + +> Example responses + +> 200 Response + +```json +{ + "positions": [ + { + "market": "string", + "status": "OPEN", + "side": "LONG", + "size": "string", + "maxSize": "string", + "entryPrice": "string", + "realizedPnl": "string", + "createdAt": "string", + "createdAtHeight": "string", + "sumOpen": "string", + "sumClose": "string", + "netFunding": "string", + "unrealizedPnl": "string", + "closedAt": "string", + "exitPrice": "string", + "subaccountNumber": 0 + } + ] +} +``` + +### Responses + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Ok|[PerpetualPositionResponse](#schemaperpetualpositionresponse)| + + + ## Get diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 5de5c88151..96b6394ce6 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -2205,6 +2205,80 @@ ] } }, + "/perpetualPositions/parentSubaccountNumber": { + "get": { + "operationId": "ListPositionsForParentSubaccount", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PerpetualPositionResponse" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "address", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "parentSubaccountNumber", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PerpetualPositionStatus" + } + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "createdBeforeOrAtHeight", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "createdBeforeOrAt", + "required": false, + "schema": { + "$ref": "#/components/schemas/IsoString" + } + } + ] + } + }, "/sparklines": { "get": { "operationId": "Get", diff --git a/indexer/services/comlink/src/controllers/api/v4/perpetual-positions-controller.ts b/indexer/services/comlink/src/controllers/api/v4/perpetual-positions-controller.ts index f54c961f92..1607a82377 100644 --- a/indexer/services/comlink/src/controllers/api/v4/perpetual-positions-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/perpetual-positions-controller.ts @@ -36,10 +36,11 @@ import { handleControllerError, getPerpetualPositionsWithUpdatedFunding, initializePerpetualPositionsWithFunding, + getChildSubaccountNums, } from '../../../lib/helpers'; import { rateLimiterMiddleware } from '../../../lib/rate-limit'; import { - CheckLimitAndCreatedBeforeOrAtSchema, + CheckLimitAndCreatedBeforeOrAtSchema, CheckParentSubaccountSchema, CheckSubaccountSchema, } from '../../../lib/validation/schemas'; import { handleValidationErrors } from '../../../request-helpers/error-handler'; @@ -47,7 +48,12 @@ import ExportResponseCodeStats from '../../../request-helpers/export-response-co import { perpetualPositionToResponseObject } from '../../../request-helpers/request-transformer'; import { sanitizeArray } from '../../../request-helpers/sanitizers'; import { validateArray } from '../../../request-helpers/validators'; -import { PerpetualPositionRequest, PerpetualPositionResponse, PerpetualPositionWithFunding } from '../../../types'; +import { + ParentSubaccountPerpetualPositionRequest, + PerpetualPositionRequest, + PerpetualPositionResponse, + PerpetualPositionWithFunding, +} from '../../../types'; const router: express.Router = express.Router(); const controllerName: string = 'perpetual-positions-controller'; @@ -150,6 +156,143 @@ class PerpetualPositionsController extends Controller { }), }; } + + @Get('/parentSubaccountNumber') + async listPositionsForParentSubaccount( + @Query() address: string, + @Query() parentSubaccountNumber: number, + @Query() status?: PerpetualPositionStatus[], + @Query() limit?: number, + @Query() createdBeforeOrAtHeight?: number, + @Query() createdBeforeOrAt?: IsoString, + ): Promise { + // Get subaccountIds for all child subaccounts of the parent subaccount + // Create a record of subaccountId to subaccount number + const childIdtoSubaccountNumber: Record = {}; + getChildSubaccountNums(parentSubaccountNumber).forEach( + (subaccountNum: number) => { + childIdtoSubaccountNumber[SubaccountTable.uuid(address, subaccountNum)] = subaccountNum; + }, + ); + const childSubaccountIds: string[] = Object.keys(childIdtoSubaccountNumber); + + const [ + positions, + markets, + ]: [ + PerpetualPositionFromDatabase[], + MarketFromDatabase[], + ] = await Promise.all([ + PerpetualPositionTable.findAll( + { + subaccountId: childSubaccountIds, + status, + limit, + createdBeforeOrAtHeight: createdBeforeOrAtHeight + ? createdBeforeOrAtHeight.toString() + : undefined, + createdBeforeOrAt, + }, + [QueryableField.LIMIT], + ), + MarketTable.findAll( + {}, + [], + ), + ]); + + const openPositionsExist: boolean = positions.some( + (position: PerpetualPositionFromDatabase) => position.status === PerpetualPositionStatus.OPEN, + ); + + let updatedPerpetualPositions: + PerpetualPositionWithFunding[] = initializePerpetualPositionsWithFunding(positions); + + // Only update the funding for open positions + if (openPositionsExist) { + const subaccountIds: string[] = updatedPerpetualPositions.map( + (position: PerpetualPositionFromDatabase) => position.subaccountId, + ); + const [ + subaccounts, + latestBlock, + ]: [ + SubaccountFromDatabase[], + BlockFromDatabase | undefined, + ] = await Promise.all([ + SubaccountTable.findAll( + { + id: subaccountIds, + }, + [], + ), + BlockTable.getLatest(), + ]); + + if (subaccounts.length === 0 || latestBlock === undefined) { + throw new NotFoundError( + `Found OPEN perpetual positions but no subaccounts with address ${address} ` + + `and parent Subaccount number ${parentSubaccountNumber}`, + ); + } + + const perpetualPositionsBySubaccount: + { [subaccountId: string]: PerpetualPositionWithFunding[] } = _.groupBy( + updatedPerpetualPositions, + 'subaccountId', + ); + + // For each subaccount, update all perpetual positions with the latest funding and + // store the updated positions in updatedPerpetualPositions. + const updatedPerpetualPositionsPromises: + Promise[] = subaccounts.map( + (subaccount: SubaccountFromDatabase) => adjustPerpetualPositionsWithUpdatedFunding( + perpetualPositionsBySubaccount[subaccount.id], + subaccount, + latestBlock, + ), + ); + updatedPerpetualPositions = _.flatten(await Promise.all(updatedPerpetualPositionsPromises)); + } + + const perpetualMarketsMap: PerpetualMarketsMap = perpetualMarketRefresher + .getPerpetualMarketsMap(); + + const marketIdToMarket: MarketsMap = _.keyBy( + markets, + MarketColumns.id, + ); + + return { + positions: updatedPerpetualPositions.map((position: PerpetualPositionWithFunding) => { + return perpetualPositionToResponseObject( + position, + perpetualMarketsMap, + marketIdToMarket, + childIdtoSubaccountNumber[position.subaccountId], + ); + }), + }; + } +} + +async function adjustPerpetualPositionsWithUpdatedFunding( + perpetualPositions: PerpetualPositionWithFunding[], + subaccount: SubaccountFromDatabase, + latestBlock: BlockFromDatabase, +): Promise { + const { + lastUpdatedFundingIndexMap, + latestFundingIndexMap, + }: { + lastUpdatedFundingIndexMap: FundingIndexMap, + latestFundingIndexMap: FundingIndexMap, + } = await getFundingIndexMaps(subaccount, latestBlock); + return getPerpetualPositionsWithUpdatedFunding( + perpetualPositions, + latestFundingIndexMap, + lastUpdatedFundingIndexMap, + ); } router.get( @@ -216,4 +359,70 @@ router.get( }, ); +router.get( + '/parentSubaccountNumber', + rateLimiterMiddleware(getReqRateLimiter), + ...CheckParentSubaccountSchema, + ...CheckLimitAndCreatedBeforeOrAtSchema, + ...checkSchema({ + status: { + in: ['query'], + optional: true, + customSanitizer: { + options: sanitizeArray, + }, + custom: { + options: (inputArray) => validateArray(inputArray, Object.values(PerpetualPositionStatus)), + errorMessage: 'status must be a valid Position Status (OPEN, etc)', + }, + }, + }), + handleValidationErrors, + complianceAndGeoCheck, + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + const start: number = Date.now(); + const { + address, + parentSubaccountNumber, + status, + limit, + createdBeforeOrAtHeight, + createdBeforeOrAt, + }: ParentSubaccountPerpetualPositionRequest = matchedData( + req, + ) as ParentSubaccountPerpetualPositionRequest; + + // The schema checks allow subaccountNumber to be a string, but we know it's a number here. + const parentSubaccountNum: number = +parentSubaccountNumber; + + try { + const controller: PerpetualPositionsController = new PerpetualPositionsController(); + const response: PerpetualPositionResponse = await controller.listPositionsForParentSubaccount( + address, + parentSubaccountNum, + status, + limit, + createdBeforeOrAtHeight, + createdBeforeOrAt, + ); + + return res.send(response); + } catch (error) { + return handleControllerError( + 'PerpetualPositionsController GET /parentSubaccountNumber', + 'Perpetual positions error', + error, + req, + res, + ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.get_perpetual_positions_parent_subaccount.timing`, + Date.now() - start, + ); + } + }, +); + export default router; diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts index c61da8f2aa..61bf8e4007 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -374,6 +374,11 @@ export interface PerpetualPositionRequest extends SubaccountRequest, LimitAndCre status: PerpetualPositionStatus[], } +export interface ParentSubaccountPerpetualPositionRequest extends ParentSubaccountRequest, + LimitAndCreatedBeforeRequest { + status: PerpetualPositionStatus[], +} + export interface AssetPositionRequest extends SubaccountRequest {} export interface ParentSubaccountAssetPositionRequest extends ParentSubaccountRequest {