diff --git a/.github/workflows/indexer-build-and-push-dev-staging.yml b/.github/workflows/indexer-build-and-push-dev-staging.yml index 5a98b72552..6d8f22c6a1 100644 --- a/.github/workflows/indexer-build-and-push-dev-staging.yml +++ b/.github/workflows/indexer-build-and-push-dev-staging.yml @@ -6,53 +6,14 @@ on: # yamllint disable-line rule:truthy - main - 'release/indexer/v[0-9]+.[0-9]+.x' # e.g. release/indexer/v0.1.x - 'release/indexer/v[0-9]+.x' # e.g. release/indexer/v1.x + - 'adam/ct-1210-new-indexer-endpoint-for-all-mid-market-prices' # TODO(DEC-837): Customize github build and push to ECR by service with paths jobs: - # Build and push to dev - call-build-and-push-ecs-services-dev: - name: (Dev) Build and Push ECS Services - uses: ./.github/workflows/indexer-build-and-push-all-ecr-images.yml - with: - ENVIRONMENT: dev - secrets: inherit - # Build and push to dev2 call-build-and-push-ecs-services-dev2: name: (Dev2) Build and Push ECS Services uses: ./.github/workflows/indexer-build-and-push-all-ecr-images.yml with: ENVIRONMENT: dev2 - secrets: inherit - - # Build and push to dev3 - call-build-and-push-ecs-services-dev3: - name: (Dev3) Build and Push ECS Services - uses: ./.github/workflows/indexer-build-and-push-all-ecr-images.yml - with: - ENVIRONMENT: dev3 - secrets: inherit - - # Build and push to dev4 - call-build-and-push-ecs-services-dev4: - name: (Dev4) Build and Push ECS Services - uses: ./.github/workflows/indexer-build-and-push-all-ecr-images.yml - with: - ENVIRONMENT: dev4 - secrets: inherit - - # Build and push to dev5 - call-build-and-push-ecs-services-dev5: - name: (Dev5) Build and Push ECS Services - uses: ./.github/workflows/indexer-build-and-push-all-ecr-images.yml - with: - ENVIRONMENT: dev5 - secrets: inherit - - # Build and push to staging - call-build-and-push-ecs-services-staging: - name: (Staging) Build and Push ECS Services - uses: ./.github/workflows/indexer-build-and-push-all-ecr-images.yml - with: - ENVIRONMENT: staging - secrets: inherit + secrets: inherit \ No newline at end of file diff --git a/indexer/packages/postgres/__tests__/stores/perpetual-market-table.test.ts b/indexer/packages/postgres/__tests__/stores/perpetual-market-table.test.ts index 8770bed421..28a2c51a77 100644 --- a/indexer/packages/postgres/__tests__/stores/perpetual-market-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/perpetual-market-table.test.ts @@ -7,7 +7,10 @@ import { defaultLiquidityTier2, defaultMarket, defaultMarket2, + defaultMarket3, defaultPerpetualMarket, + defaultPerpetualMarket2, + defaultPerpetualMarket3, invalidTicker, } from '../helpers/constants'; import * as MarketTable from '../../src/stores/market-table'; @@ -21,6 +24,7 @@ describe('PerpetualMarket store', () => { await Promise.all([ MarketTable.create(defaultMarket), MarketTable.create(defaultMarket2), + MarketTable.create(defaultMarket3), ]); await Promise.all([ LiquidityTiersTable.create(defaultLiquidityTier), @@ -192,4 +196,55 @@ describe('PerpetualMarket store', () => { trades24H: 100, })); }); + + it('Successfully finds all PerpetualMarkets with specific tickers', async () => { + await Promise.all([ + PerpetualMarketTable.create(defaultPerpetualMarket), + PerpetualMarketTable.create(defaultPerpetualMarket2), + PerpetualMarketTable.create(defaultPerpetualMarket3), + ]); + + const perpetualMarkets: PerpetualMarketFromDatabase[] = await PerpetualMarketTable.findAll( + { tickers: ['BTC-USD', 'ETH-USD'] }, + [], + { readReplica: true }, + ); + + expect(perpetualMarkets.length).toEqual(2); + expect(perpetualMarkets[0].ticker).toEqual('BTC-USD'); + expect(perpetualMarkets[1].ticker).toEqual('ETH-USD'); + }); + + it('Returns empty array when no PerpetualMarkets match the given tickers', async () => { + const perpetualMarkets: PerpetualMarketFromDatabase[] = await PerpetualMarketTable.findAll( + { tickers: ['BAD-TICKER'] }, + [], + ); + + expect(perpetualMarkets.length).toEqual(0); + }); + + it('Successfully combines tickers filter with other filters', async () => { + await Promise.all([ + PerpetualMarketTable.create(defaultPerpetualMarket), + PerpetualMarketTable.create(defaultPerpetualMarket2), + PerpetualMarketTable.create({ + ...defaultPerpetualMarket3, + liquidityTierId: defaultLiquidityTier2.id, + }), + ]); + + const perpetualMarkets: PerpetualMarketFromDatabase[] = await PerpetualMarketTable.findAll( + { + tickers: ['BTC-USD', 'ETH-USD', 'LINK-USD'], + liquidityTierId: [defaultLiquidityTier.id], + }, + [], + { readReplica: true }, + ); + + expect(perpetualMarkets.length).toEqual(2); + expect(perpetualMarkets[0].ticker).toEqual('BTC-USD'); + expect(perpetualMarkets[1].ticker).toEqual('ETH-USD'); + }); }); diff --git a/indexer/packages/postgres/src/stores/perpetual-market-table.ts b/indexer/packages/postgres/src/stores/perpetual-market-table.ts index a0ea3e82e0..e43459e076 100644 --- a/indexer/packages/postgres/src/stores/perpetual-market-table.ts +++ b/indexer/packages/postgres/src/stores/perpetual-market-table.ts @@ -31,6 +31,7 @@ export async function findAll( id, marketId, liquidityTierId, + tickers, limit, }: PerpetualMarketQueryConfig, requiredFields: QueryableField[], @@ -59,6 +60,10 @@ export async function findAll( baseQuery = baseQuery.whereIn(PerpetualMarketColumns.marketId, marketId); } + if (tickers !== undefined) { + baseQuery = baseQuery.whereIn(PerpetualMarketColumns.ticker, tickers); + } + if (liquidityTierId !== undefined) { baseQuery = baseQuery.whereIn(PerpetualMarketColumns.liquidityTierId, liquidityTierId); } diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index c5c18cab71..ed5921433b 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -60,6 +60,7 @@ export enum QueryableField { GOOD_TIL_BLOCK_BEFORE_OR_AT = 'goodTilBlockBeforeOrAt', GOOD_TIL_BLOCK_TIME_BEFORE_OR_AT = 'goodTilBlockTimeBeforeOrAt', TICKER = 'ticker', + TICKERS = 'tickers', RESOLUTION = 'resolution', FROM_ISO = 'fromISO', TO_ISO = 'toISO', @@ -149,6 +150,7 @@ export interface OrderQueryConfig extends QueryConfig { export interface PerpetualMarketQueryConfig extends QueryConfig { [QueryableField.ID]?: string[], [QueryableField.MARKET_ID]?: number[], + [QueryableField.TICKERS]?: string[], [QueryableField.LIQUIDITY_TIER_ID]?: number[], } diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-markets-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-markets-controller.test.ts index 3da47292c6..45215989ee 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-markets-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-markets-controller.test.ts @@ -11,12 +11,22 @@ import { LiquidityTiersTable, liquidityTierRefresher, } from '@dydxprotocol-indexer/postgres'; +import { + OrderbookMidPricesCache, +} from '@dydxprotocol-indexer/redis'; import { RequestMethod } from '../../../../src/types'; import request from 'supertest'; import { getQueryString, sendRequest } from '../../../helpers/helpers'; import _ from 'lodash'; import { perpetualMarketToResponseObject } from '../../../../src/request-helpers/request-transformer'; +jest.mock('@dydxprotocol-indexer/redis', () => ({ + ...jest.requireActual('@dydxprotocol-indexer/redis'), + OrderbookMidPricesCache: { + getMedianPrice: jest.fn(), + }, +})); + describe('perpetual-markets-controller#V4', () => { beforeAll(async () => { await dbHelpers.migrate(); @@ -30,10 +40,12 @@ describe('perpetual-markets-controller#V4', () => { afterAll(async () => { await dbHelpers.teardown(); + jest.resetAllMocks(); }); afterEach(async () => { await dbHelpers.clearData(); + jest.clearAllMocks(); }); describe('/', () => { @@ -128,6 +140,132 @@ describe('perpetual-markets-controller#V4', () => { })); }); }); + + describe('GET /v4/perpetualMarkets/orderbookMidPrices', () => { + it('returns mid prices for all markets when no tickers are specified', async () => { + (OrderbookMidPricesCache.getMedianPrice as jest.Mock).mockImplementation((client, ticker) => { + const prices: {[key: string]: string} = { + 'BTC-USD': '30000.5', + 'ETH-USD': '2000.25', + 'SHIB-USD': '5.75', + }; + return Promise.resolve(prices[ticker]); + }); + + const response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/perpetualMarkets/orderbookMidPrices', + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + 'BTC-USD': '30000.5', + 'ETH-USD': '2000.25', + 'SHIB-USD': '5.75', + }); + const numMarkets = (await PerpetualMarketTable.findAll({}, [])).length; + expect(OrderbookMidPricesCache.getMedianPrice).toHaveBeenCalledTimes(numMarkets); + }); + + it('returns mid prices for multiple specified tickers', async () => { + (OrderbookMidPricesCache.getMedianPrice as jest.Mock).mockImplementation((client, ticker) => { + const prices: {[key: string]: string} = { + 'BTC-USD': '30000.5', + 'ETH-USD': '2000.25', + }; + return Promise.resolve(prices[ticker]); + }); + + const response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/perpetualMarkets/orderbookMidPrices?tickers=BTC-USD&tickers=ETH-USD', + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + 'BTC-USD': '30000.5', + 'ETH-USD': '2000.25', + }); + + expect(OrderbookMidPricesCache.getMedianPrice).toHaveBeenCalledTimes(2); + }); + + it('returns mid prices for one specified ticker', async () => { + (OrderbookMidPricesCache.getMedianPrice as jest.Mock).mockImplementation((client, ticker) => { + const prices: {[key: string]: string} = { + 'BTC-USD': '30000.5', + 'ETH-USD': '2000.25', + }; + return Promise.resolve(prices[ticker]); + }); + + const response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/perpetualMarkets/orderbookMidPrices?tickers=BTC-USD', + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + 'BTC-USD': '30000.5', + }); + + expect(OrderbookMidPricesCache.getMedianPrice).toHaveBeenCalledTimes(1); + }); + + it('omits markets with no mid price', async () => { + (OrderbookMidPricesCache.getMedianPrice as jest.Mock).mockImplementation((client, ticker) => { + const prices: {[key: string]: string | null} = { + 'BTC-USD': '30000.5', + 'ETH-USD': null, + 'SHIB-USD': '5.75', + }; + return Promise.resolve(prices[ticker]); + }); + + const response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/perpetualMarkets/orderbookMidPrices', + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + 'BTC-USD': '30000.5', + 'SHIB-USD': '5.75', + }); + }); + + it('returns an empty object when no markets have mid prices', async () => { + (OrderbookMidPricesCache.getMedianPrice as jest.Mock).mockResolvedValue(null); + + const response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/perpetualMarkets/orderbookMidPrices', + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({}); + }); + + it('returns prices only for valid tickers and ignores invalid tickers', async () => { + (OrderbookMidPricesCache.getMedianPrice as jest.Mock).mockImplementation((client, ticker) => { + const prices: {[key: string]: string} = { + 'BTC-USD': '30000.5', + }; + return Promise.resolve(prices[ticker]); + }); + + const response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/perpetualMarkets/orderbookMidPrices?tickers=BTC-USD&tickers=INVALID-TICKER', + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + 'BTC-USD': '30000.5', + }); + expect(OrderbookMidPricesCache.getMedianPrice).toHaveBeenCalledTimes(1); + }); + }); }); function expectResponseWithMarkets( diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index 4d08bf1430..4c2a957753 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -2542,6 +2542,89 @@ fetch(`${baseURL}/perpetualMarkets`, This operation does not require authentication +## GetOrderbookMidPrices + + + +> Code samples + +```python +import requests +headers = { + 'Accept': 'application/json' +} + +# For the deployment by DYDX token holders, use +# baseURL = 'https://indexer.dydx.trade/v4' +baseURL = 'https://dydx-testnet.imperator.co/v4' + +r = requests.get(f'{baseURL}/perpetualMarkets/orderbookMidPrices', headers = headers) + +print(r.json()) + +``` + +```javascript + +const headers = { + 'Accept':'application/json' +}; + +// For the deployment by DYDX token holders, use +// const baseURL = 'https://indexer.dydx.trade/v4'; +const baseURL = 'https://dydx-testnet.imperator.co/v4'; + +fetch(`${baseURL}/perpetualMarkets/orderbookMidPrices`, +{ + method: 'GET', + + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`GET /perpetualMarkets/orderbookMidPrices` + +### Parameters + +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|tickers|query|array[string]|false|none| + +> Example responses + +> 200 Response + +```json +{ + "property1": "string", + "property2": "string" +} +``` + +### Responses + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Ok|Inline| + +### Response Schema + +Status Code **200** + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|ยป **additionalProperties**|string|false|none|none| + + + ## ListPositions diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 23de12c60c..6f3601e064 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -2925,6 +2925,41 @@ ] } }, + "/perpetualMarkets/orderbookMidPrices": { + "get": { + "operationId": "GetOrderbookMidPrices", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": {}, + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "tickers", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, "/perpetualPositions": { "get": { "operationId": "ListPositions", diff --git a/indexer/services/comlink/src/controllers/api/v4/perpetual-markets-controller.ts b/indexer/services/comlink/src/controllers/api/v4/perpetual-markets-controller.ts index f8a95a0dfc..75dfb60455 100644 --- a/indexer/services/comlink/src/controllers/api/v4/perpetual-markets-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/perpetual-markets-controller.ts @@ -9,6 +9,7 @@ import { LiquidityTiersMap, LiquidityTiersFromDatabase, } from '@dydxprotocol-indexer/postgres'; +import { OrderbookMidPricesCache } from '@dydxprotocol-indexer/redis'; import express from 'express'; import { matchedData, @@ -20,12 +21,13 @@ import { import { getReqRateLimiter } from '../../../caches/rate-limiters'; import config from '../../../config'; +import { redisClient } from '../../../helpers/redis/redis-controller'; import { NotFoundError } from '../../../lib/errors'; import { handleControllerError, } from '../../../lib/helpers'; import { rateLimiterMiddleware } from '../../../lib/rate-limit'; -import { CheckLimitSchema, CheckTickerOptionalQuerySchema } from '../../../lib/validation/schemas'; +import { CheckLimitSchema, CheckTickerOptionalQuerySchema, CheckTickersQuerySchema } from '../../../lib/validation/schemas'; import { handleValidationErrors } from '../../../request-helpers/error-handler'; import ExportResponseCodeStats from '../../../request-helpers/export-response-code-stats'; import { perpetualMarketToResponseObject } from '../../../request-helpers/request-transformer'; @@ -108,6 +110,28 @@ class PerpetualMarketsController extends Controller { .value(), }; } + + @Get('/orderbookMidPrices') + async getOrderbookMidPrices( + @Query() tickers?: string[], + ): Promise<{ [ticker: string]: string }> { + // Convert tickers to an array if it's a single string + const tickersArray: string[] = typeof tickers === 'string' ? [tickers] : tickers || []; + + const perpetualMarkets: PerpetualMarketFromDatabase[] = tickersArray.length > 0 + ? await PerpetualMarketTable.findAll({ tickers: tickersArray }, []) + : await PerpetualMarketTable.findAll({}, []); + + const orderbookMidPrices: { [ticker: string]: string } = {}; + for (const market of perpetualMarkets) { + const price = await OrderbookMidPricesCache.getMedianPrice(redisClient, market.ticker); + if (price !== null) { + orderbookMidPrices[market.ticker] = price; + } + } + + return orderbookMidPrices; + } } router.get( @@ -152,4 +176,36 @@ router.get( }, ); +router.get( + '/orderbookMidPrices', + rateLimiterMiddleware(getReqRateLimiter), + ...CheckTickersQuerySchema, + handleValidationErrors, + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + const start: number = Date.now(); + const { tickers }: { tickers?: string[] } = matchedData(req); + + try { + const controller: PerpetualMarketsController = new PerpetualMarketsController(); + const response = await controller.getOrderbookMidPrices(tickers); + + return res.send(response); + } catch (error) { + return handleControllerError( + 'PerpetualMarketController GET /orderbookMidPrices', + 'OrderbookMidPrices error', + error, + req, + res, + ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.get_orderbook_mid_prices.timing`, + Date.now() - start, + ); + } + }, +); + export default router; diff --git a/indexer/services/comlink/src/lib/validation/schemas.ts b/indexer/services/comlink/src/lib/validation/schemas.ts index 98e1d2ed7e..7c080a832c 100644 --- a/indexer/services/comlink/src/lib/validation/schemas.ts +++ b/indexer/services/comlink/src/lib/validation/schemas.ts @@ -212,6 +212,25 @@ export const CheckHistoricalBlockTradingRewardsSchema = checkSchema({ }, }); +export const CheckTickersQuerySchema = checkSchema({ + tickers: { + in: ['query'], + optional: true, + custom: { + options: (value: string | string[]) => { + if (typeof value === 'string') { + return true; // Accept a single string + } + if (Array.isArray(value)) { + return value.every((ticker) => typeof ticker === 'string'); + } + return false; // Reject anything else + }, + errorMessage: 'tickers must be a string or an array of strings', + }, + }, +}); + export const CheckTransferBetweenSchema = checkSchema(transferBetweenSchemaRecord); export const RegisterTokenValidationSchema = [