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 = [