diff --git a/.changeset/dry-clouds-nail.md b/.changeset/dry-clouds-nail.md new file mode 100644 index 0000000000..99a2fb012b --- /dev/null +++ b/.changeset/dry-clouds-nail.md @@ -0,0 +1,5 @@ +--- +'@chainlink/ncfx-adapter': minor +--- + +New market-status endpoint diff --git a/packages/sources/ncfx/README.md b/packages/sources/ncfx/README.md index 8d3b262e60..dbe8384882 100644 --- a/packages/sources/ncfx/README.md +++ b/packages/sources/ncfx/README.md @@ -6,13 +6,15 @@ This document was generated automatically. Please see [README Generator](../../s ## Environment Variables -| Required? | Name | Description | Type | Options | Default | -| :-------: | :-------------------: | :------------------------------------------------: | :----: | :-----: | :---------------------------------------------: | -| | API_USERNAME | Username for the NCFX Crypto API | string | | | -| | API_PASSWORD | Password for the NCFX Crypto API | string | | | -| | FOREX_WS_API_KEY | API key for Forex websocket endpoint | string | | | -| | WS_API_ENDPOINT | The WS API endpoint to use for the crypto endpoint | string | | `wss://cryptofeed.ws.newchangefx.com` | -| | FOREX_WS_API_ENDPOINT | The WS API endpoint to use for the forex endpoint | string | | `wss://fiat.ws.newchangefx.com/sub/fiat/ws/ref` | +| Required? | Name | Description | Type | Options | Default | +| :-------: | :---------------------------: | :-------------------------------------------------------: | :----: | :-----: | :--------------------------------------------------------------: | +| | API_USERNAME | Username for the NCFX Crypto API | string | | | +| | API_PASSWORD | Password for the NCFX Crypto API | string | | | +| | FOREX_WS_API_KEY | API key for Forex websocket endpoint | string | | | +| | WS_API_ENDPOINT | The WS API endpoint to use for the crypto endpoint | string | | `wss://cryptofeed.ws.newchangefx.com` | +| | FOREX_WS_API_ENDPOINT | The WS API endpoint to use for the forex endpoint | string | | `wss://fiat.ws.newchangefx.com/sub/fiat/ws/ref` | +| | MARKET_STATUS_WS_API_ENDPOINT | The WS API endpoint to use for the market status endpoint | string | | `wss://fiat.ws.newchangefx.com/general/reference/v1/markethours` | +| | MARKET_STATUS_WS_API_KEY | The WS API key to use for the market status endpoint | string | | | --- @@ -24,9 +26,9 @@ There are no rate limits for this adapter. ## Input Parameters -| Required? | Name | Description | Type | Options | Default | -| :-------: | :------: | :-----------------: | :----: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------: | -| | endpoint | The endpoint to use | string | [crypto-lwba](#crypto-lwba-endpoint), [crypto](#crypto-endpoint), [crypto_lwba](#crypto-lwba-endpoint), [cryptolwba](#crypto-lwba-endpoint), [forex](#forex-endpoint), [price](#crypto-endpoint) | `crypto` | +| Required? | Name | Description | Type | Options | Default | +| :-------: | :------: | :-----------------: | :----: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------: | +| | endpoint | The endpoint to use | string | [crypto-lwba](#crypto-lwba-endpoint), [crypto](#crypto-endpoint), [crypto_lwba](#crypto-lwba-endpoint), [cryptolwba](#crypto-lwba-endpoint), [forex-market-status](#market-status-endpoint), [forex](#forex-endpoint), [market-status](#market-status-endpoint), [metals-market-status](#market-status-endpoint), [price](#crypto-endpoint) | `crypto` | ## Crypto Endpoint @@ -55,9 +57,9 @@ Request: --- -## Crypto-lwba Endpoint +## Forex Endpoint -Supported names for this endpoint are: `crypto-lwba`, `crypto_lwba`, `cryptolwba`. +`forex` is the only supported name for this endpoint. ### Input Params @@ -73,8 +75,8 @@ Request: ```json { "data": { - "endpoint": "crypto-lwba", - "base": "ETH", + "endpoint": "forex", + "base": "CAD", "quote": "USD" } } @@ -82,9 +84,9 @@ Request: --- -## Forex Endpoint +## Crypto-lwba Endpoint -`forex` is the only supported name for this endpoint. +Supported names for this endpoint are: `crypto-lwba`, `crypto_lwba`, `cryptolwba`. ### Input Params @@ -100,8 +102,8 @@ Request: ```json { "data": { - "endpoint": "forex", - "base": "CAD", + "endpoint": "crypto-lwba", + "base": "ETH", "quote": "USD" } } @@ -109,4 +111,20 @@ Request: --- +## Market-status Endpoint + +Supported names for this endpoint are: `forex-market-status`, `market-status`, `metals-market-status`. + +### Input Params + +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :----: | :-----: | :--------------------: | :----: | :---------------: | :-----: | :--------: | :------------: | +| ✅ | market | | The name of the market | string | `forex`, `metals` | | | | + +### Example + +There are no examples for this endpoint. + +--- + MIT License diff --git a/packages/sources/ncfx/src/config/index.ts b/packages/sources/ncfx/src/config/index.ts index d9f868230c..7eeae1fa63 100644 --- a/packages/sources/ncfx/src/config/index.ts +++ b/packages/sources/ncfx/src/config/index.ts @@ -25,4 +25,14 @@ export const config = new AdapterConfig({ description: 'The WS API endpoint to use for the forex endpoint', default: 'wss://fiat.ws.newchangefx.com/sub/fiat/ws/ref', }, + MARKET_STATUS_WS_API_ENDPOINT: { + type: 'string', + description: 'The WS API endpoint to use for the market status endpoint', + default: 'wss://fiat.ws.newchangefx.com/general/reference/v1/markethours', + }, + MARKET_STATUS_WS_API_KEY: { + type: 'string', + description: 'The WS API key to use for the market status endpoint', + sensitive: true, + }, }) diff --git a/packages/sources/ncfx/src/endpoint/index.ts b/packages/sources/ncfx/src/endpoint/index.ts index ecaabe67b6..b1119156fa 100644 --- a/packages/sources/ncfx/src/endpoint/index.ts +++ b/packages/sources/ncfx/src/endpoint/index.ts @@ -1,3 +1,4 @@ export { cryptoEndpoint as crypto } from './crypto' export { cryptoLwbaEndpoint as lwba } from './crypto-lwba' export { forexEndpoint as forex } from './forex' +export { marketStatusEndpoint as marketStatus } from './market-status' diff --git a/packages/sources/ncfx/src/endpoint/market-status.ts b/packages/sources/ncfx/src/endpoint/market-status.ts new file mode 100644 index 0000000000..386ac50c61 --- /dev/null +++ b/packages/sources/ncfx/src/endpoint/market-status.ts @@ -0,0 +1,31 @@ +import { + MarketStatusEndpoint, + MarketStatusResultResponse, +} from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' + +import { config } from '../config' +import { markets, transport } from '../transport/market-status' + +const inputParameters = new InputParameters({ + market: { + aliases: [], + type: 'string', + description: 'The name of the market', + options: markets, + required: true, + }, +}) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: MarketStatusResultResponse + Settings: typeof config.settings +} + +export const marketStatusEndpoint = new MarketStatusEndpoint({ + name: 'market-status', + aliases: ['forex-market-status', 'metals-market-status'], + transport, + inputParameters, +}) diff --git a/packages/sources/ncfx/src/index.ts b/packages/sources/ncfx/src/index.ts index 71462a82a3..5f187541b9 100644 --- a/packages/sources/ncfx/src/index.ts +++ b/packages/sources/ncfx/src/index.ts @@ -2,11 +2,11 @@ import { expose, ServerInstance } from '@chainlink/external-adapter-framework' import { PriceAdapter } from '@chainlink/external-adapter-framework/adapter' import { config } from './config' import includes from './config/includes.json' -import { crypto, forex, lwba } from './endpoint' +import { crypto, forex, lwba, marketStatus } from './endpoint' export const adapter = new PriceAdapter({ name: 'NCFX', - endpoints: [crypto, lwba, forex], + endpoints: [crypto, forex, lwba, marketStatus], defaultEndpoint: crypto.name, config, includes, diff --git a/packages/sources/ncfx/src/transport/market-status.ts b/packages/sources/ncfx/src/transport/market-status.ts new file mode 100644 index 0000000000..6bb5794b1d --- /dev/null +++ b/packages/sources/ncfx/src/transport/market-status.ts @@ -0,0 +1,70 @@ +import { MarketStatus } from '@chainlink/external-adapter-framework/adapter' +import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports' +import { makeLogger, ProviderResult } from '@chainlink/external-adapter-framework/util' +import type { BaseEndpointTypes } from '../endpoint/market-status' + +export const markets = ['forex', 'metals'] as const + +export type Market = (typeof markets)[number] + +const marketToNcfxMarket: Record = { + forex: 'fx', + metals: 'metals', +} + +type WsMessage = { + marketStatus: { + fx: string + metals: string + } + timestamp: string +} + +type WsTransportTypes = BaseEndpointTypes & { + Provider: { + WsMessage: WsMessage + } +} + +const logger = makeLogger('NcfxMarketStatusEndpoint') + +export const transport = new WebSocketTransport({ + url: (context) => context.adapterSettings.MARKET_STATUS_WS_API_ENDPOINT, + options: (context) => { + return { headers: { 'x-api-key': context.adapterSettings.MARKET_STATUS_WS_API_KEY } } + }, + handlers: { + message(message: WsMessage): ProviderResult[] { + if (!('marketStatus' in message) || typeof message.marketStatus !== 'object') { + logger.warn('Invalid marketStatus field in response') + return [] + } + return markets.map((market) => { + const marketStatus = parseMarketStatus(message.marketStatus[marketToNcfxMarket[market]]) + return { + params: { market }, + response: { + result: marketStatus, + data: { + result: marketStatus, + }, + timestamps: { + providerIndicatedTimeUnixMs: new Date(message.timestamp).getTime(), + }, + }, + } + }) + }, + }, +}) + +export function parseMarketStatus(marketStatus: string): MarketStatus { + if (marketStatus === 'open') { + return MarketStatus.OPEN + } + if (marketStatus === 'closed') { + return MarketStatus.CLOSED + } + logger.warn(`Unexpected market status value: ${marketStatus}`) + return MarketStatus.UNKNOWN +} diff --git a/packages/sources/ncfx/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/ncfx/test/integration/__snapshots__/adapter.test.ts.snap index 547081b2d7..76bc70c58b 100644 --- a/packages/sources/ncfx/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/sources/ncfx/test/integration/__snapshots__/adapter.test.ts.snap @@ -57,3 +57,33 @@ exports[`websocket lwba endpoint should return success 1`] = ` }, } `; + +exports[`websocket market status endpoint should return success with closed 1`] = ` +{ + "data": { + "result": 1, + }, + "result": 1, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1015, + "providerDataStreamEstablishedUnixMs": 1010, + "providerIndicatedTimeUnixMs": 1718916249594, + }, +} +`; + +exports[`websocket market status endpoint should return success with open 1`] = ` +{ + "data": { + "result": 2, + }, + "result": 2, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1015, + "providerDataStreamEstablishedUnixMs": 1010, + "providerIndicatedTimeUnixMs": 1718916249594, + }, +} +`; diff --git a/packages/sources/ncfx/test/integration/adapter.test.ts b/packages/sources/ncfx/test/integration/adapter.test.ts index 79a4e17c0b..0fbd6382fd 100644 --- a/packages/sources/ncfx/test/integration/adapter.test.ts +++ b/packages/sources/ncfx/test/integration/adapter.test.ts @@ -1,4 +1,3 @@ -import { mockCryptoWebSocketServer, mockForexWebSocketServer } from './fixtures' import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' import { TestAdapter, @@ -6,15 +5,24 @@ import { mockWebSocketProvider, MockWebsocketServer, } from '@chainlink/external-adapter-framework/util/testing-utils' -import FakeTimers from '@sinonjs/fake-timers' import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import FakeTimers from '@sinonjs/fake-timers' + +import { + mockCryptoWebSocketServer, + mockForexResponse, + mockForexWebSocketServer, + mockMarketStatusWebSocketServer, +} from './fixtures' describe('websocket', () => { let mockWsServer: MockWebsocketServer | undefined let mockWsServerForex: MockWebsocketServer | undefined + let mockWsServerMarketStatus: MockWebsocketServer | undefined let testAdapter: TestAdapter const wsEndpoint = 'ws://localhost:9090' const wsEndpointForex = 'ws://localhost:9091' + const wsEndpointMarketStatus = 'ws://localhost:9092' let oldEnv: NodeJS.ProcessEnv const cryptoData = { base: 'eth', @@ -35,6 +43,14 @@ describe('websocket', () => { quote: 'USD', endpoint: 'forex', } + const marketStatusOpenData = { + market: 'forex', + endpoint: 'market-status', + } + const marketStatusClosedData = { + market: 'metals', + endpoint: 'market-status', + } beforeAll(async () => { oldEnv = JSON.parse(JSON.stringify(process.env)) @@ -44,13 +60,16 @@ describe('websocket', () => { process.env['METRICS_ENABLED'] = 'false' process.env['WS_API_ENDPOINT'] = wsEndpoint process.env['FOREX_WS_API_ENDPOINT'] = wsEndpointForex + process.env['MARKET_STATUS_WS_API_ENDPOINT'] = wsEndpointMarketStatus process.env['API_USERNAME'] = 'test-api-username' process.env['API_PASSWORD'] = 'test-api-password' process.env['FOREX_WS_API_KEY'] = 'test-api-key' + process.env['MARKET_STATUS_WS_API_KEY'] = 'test-api-key' mockWebSocketProvider(WebSocketClassProvider) mockWsServer = mockCryptoWebSocketServer(wsEndpoint) mockWsServerForex = mockForexWebSocketServer(wsEndpointForex) + mockWsServerMarketStatus = mockMarketStatusWebSocketServer(wsEndpointMarketStatus) const adapter = (await import('./../../src')).adapter as unknown as Adapter testAdapter = await TestAdapter.startWithMockedCache(adapter, { @@ -63,13 +82,16 @@ describe('websocket', () => { await testAdapter.request(cryptoDataLwba) await testAdapter.request(cryptoDataLwbaInvariantViolation) await testAdapter.request(forexData) - await testAdapter.waitForCache(7) + await testAdapter.request(marketStatusOpenData) + await testAdapter.request(marketStatusClosedData) + await testAdapter.waitForCache(6 + Object.keys(mockForexResponse).length) }) afterAll(async () => { setEnvVariables(oldEnv) mockWsServer?.close() mockWsServerForex?.close() + mockWsServerMarketStatus?.close() testAdapter.clock?.uninstall() await testAdapter.api.close() }) @@ -129,4 +151,28 @@ describe('websocket', () => { expect(response.statusCode).toEqual(400) }) }) + + describe('market status endpoint', () => { + it('should return success with open', async () => { + const response = await testAdapter.request(marketStatusOpenData) + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return success with closed', async () => { + const response = await testAdapter.request(marketStatusClosedData) + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error (empty market)', async () => { + const response = await testAdapter.request({ ...marketStatusOpenData, market: undefined }) + expect(response.statusCode).toEqual(400) + }) + + it('should return error (unknown market)', async () => { + const response = await testAdapter.request({ ...marketStatusOpenData, market: 'unknown' }) + expect(response.statusCode).toEqual(400) + }) + }) }) diff --git a/packages/sources/ncfx/test/integration/fixtures.ts b/packages/sources/ncfx/test/integration/fixtures.ts index 8ac2a3fa3a..bb636a35a6 100644 --- a/packages/sources/ncfx/test/integration/fixtures.ts +++ b/packages/sources/ncfx/test/integration/fixtures.ts @@ -125,6 +125,14 @@ export const mockForexResponse = { XPTUSD: { price: 903.67, timestamp: '2022-08-01T07:14:54.508Z' }, } +export const mockMarketStatusResponse = { + marketStatus: { + fx: 'open', + metals: 'closed', + }, + timestamp: '2024-06-20T20:44:09.594Z', +} + export const mockCryptoWebSocketServer = (URL: string): MockWebsocketServer => { const mockWsServer = new MockWebsocketServer(URL, { mock: false }) mockWsServer.on('connection', (socket) => { @@ -148,3 +156,13 @@ export const mockForexWebSocketServer = (URL: string): MockWebsocketServer => { }) return mockWsServer } + +export const mockMarketStatusWebSocketServer = (URL: string): MockWebsocketServer => { + const mockWsServer = new MockWebsocketServer(URL, { mock: false }) + mockWsServer.on('connection', (socket) => { + setTimeout(() => { + socket.send(JSON.stringify(mockMarketStatusResponse)) + }, 0) + }) + return mockWsServer +}