-
Notifications
You must be signed in to change notification settings - Fork 260
Implement price in six #4961
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Implement price in six #4961
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@chainlink/six-adapter': patch | ||
| --- | ||
|
|
||
| Price endpoint | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,64 +1,73 @@ | ||
| import { AdapterConfig } from '@chainlink/external-adapter-framework/config' | ||
|
|
||
| export const config = new AdapterConfig({ | ||
| WS_API_ENDPOINT: { | ||
| description: 'SIX WebSocket API endpoint', | ||
| type: 'string', | ||
| default: 'wss://api.six-group.com/web/v2/websocket', | ||
| sensitive: false, | ||
| }, | ||
| API_ENDPOINT: { | ||
| description: 'SIX REST API base URL', | ||
| type: 'string', | ||
| default: 'https://api.six-group.com', | ||
| sensitive: false, | ||
| }, | ||
| PRIVATE_KEY: { | ||
| description: | ||
| 'The private key that starts with "-----BEGIN PRIVATE KEY-----" and end with "-----END PRIVATE KEY-----"', | ||
| type: 'string', | ||
| required: true, | ||
| sensitive: true, | ||
| validate: { | ||
| meta: { | ||
| details: 'Value must be a valid private key', | ||
| export const config = new AdapterConfig( | ||
| { | ||
| WS_API_ENDPOINT: { | ||
| description: 'SIX WebSocket API endpoint', | ||
| type: 'string', | ||
| default: 'wss://api.six-group.com/web/v2/websocket', | ||
| sensitive: false, | ||
| }, | ||
| API_ENDPOINT: { | ||
| description: 'SIX REST API base URL', | ||
| type: 'string', | ||
| default: 'https://api.six-group.com', | ||
| sensitive: false, | ||
| }, | ||
| PRIVATE_KEY: { | ||
| description: | ||
| 'The private key that starts with "-----BEGIN PRIVATE KEY-----" and end with "-----END PRIVATE KEY-----"', | ||
| type: 'string', | ||
| required: true, | ||
| sensitive: true, | ||
| validate: { | ||
| meta: { | ||
| details: 'Value must be a valid private key', | ||
| }, | ||
| fn: (value) => { | ||
| if ( | ||
| !( | ||
| value && | ||
| value.startsWith('-----BEGIN PRIVATE KEY-----\n') && | ||
| value.endsWith('\n-----END PRIVATE KEY-----') | ||
| ) | ||
| ) { | ||
| return 'Value must be a valid private key that starts with "-----BEGIN PRIVATE KEY-----\\n" and end with "\\n-----END PRIVATE KEY-----"' | ||
| } | ||
| return | ||
| }, | ||
| }, | ||
| fn: (value) => { | ||
| if ( | ||
| !( | ||
| value && | ||
| value.startsWith('-----BEGIN PRIVATE KEY-----\n') && | ||
| value.endsWith('\n-----END PRIVATE KEY-----') | ||
| ) | ||
| ) { | ||
| return 'Value must be a valid private key that starts with "-----BEGIN PRIVATE KEY-----\\n" and end with "\\n-----END PRIVATE KEY-----"' | ||
| } | ||
| return | ||
| }, | ||
| PUBLIC_CERT: { | ||
| description: | ||
| 'The public certificate that starts with "-----BEGIN CERTIFICATE-----" and end with "-----END CERTIFICATE-----"', | ||
| type: 'string', | ||
| required: true, | ||
| sensitive: false, | ||
| validate: { | ||
| meta: { | ||
| details: 'Value must be a valid public certificate', | ||
| }, | ||
| fn: (value) => { | ||
| if ( | ||
| !( | ||
| value && | ||
| value.startsWith('-----BEGIN CERTIFICATE-----\n') && | ||
| value.endsWith('\n-----END CERTIFICATE-----') | ||
| ) | ||
| ) { | ||
| return 'Value must be a valid public certificate that starts with "-----BEGIN CERTIFICATE-----\\n" and end with "\\n-----END CERTIFICATE-----"' | ||
| } | ||
| return | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| PUBLIC_CERT: { | ||
| description: | ||
| 'The public certificate that starts with "-----BEGIN CERTIFICATE-----" and end with "-----END CERTIFICATE-----"', | ||
| type: 'string', | ||
| required: true, | ||
| sensitive: false, | ||
| validate: { | ||
| meta: { | ||
| details: 'Value must be a valid public certificate', | ||
| }, | ||
| fn: (value) => { | ||
| if ( | ||
| !( | ||
| value && | ||
| value.startsWith('-----BEGIN CERTIFICATE-----\n') && | ||
| value.endsWith('\n-----END CERTIFICATE-----') | ||
| ) | ||
| ) { | ||
| return 'Value must be a valid public certificate that starts with "-----BEGIN CERTIFICATE-----\\n" and end with "\\n-----END CERTIFICATE-----"' | ||
| } | ||
| return | ||
| }, | ||
| { | ||
| envDefaultOverrides: { | ||
| // To maintain a stable WebSocket connection, clients must send a ping every 10 seconds | ||
| // Doing so every 5 seconds here to account for delay etc... | ||
| WS_HEARTBEAT_INTERVAL_MS: 5_000, | ||
| }, | ||
| }, | ||
| }) | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export { endpoint as marketStatus } from './market-status' | ||
| export { endpoint as stock } from './stock' |
|
mxiao-cll marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' | ||
| import { stockEndpointInputParametersDefinition } from '@chainlink/external-adapter-framework/adapter/stock' | ||
| import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' | ||
| import { InputParameters } from '@chainlink/external-adapter-framework/validation' | ||
| import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' | ||
| import { config } from '../config' | ||
| import { wsTransport } from '../transport/stock' | ||
|
|
||
| export const inputParameters = new InputParameters( | ||
| { | ||
| ...stockEndpointInputParametersDefinition, | ||
| rawEndpoint: { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this available in the params? Isn't it provided by the framework?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Framework strip endpoint out before passing it down, so we don't have access to that here. |
||
| type: 'string', | ||
| description: 'The value of endpoint input', | ||
| }, | ||
| }, | ||
| [ | ||
| { | ||
| base: 'ABBN_4', | ||
| rawEndpoint: '', | ||
| }, | ||
| ], | ||
| ) | ||
|
|
||
| export type BaseEndpointTypes = { | ||
| Parameters: typeof inputParameters.definition | ||
| Settings: typeof config.settings | ||
| Response: | ||
| | SingleNumberResultResponse | ||
| | { | ||
| Result: null | ||
| Data: { | ||
| mid_price: number | ||
| bid_price: number | ||
| bid_volume: number | ||
| ask_price: number | ||
| ask_volume: number | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const endpoint = new AdapterEndpoint({ | ||
| name: 'stock', | ||
| aliases: ['stock_quotes'], | ||
| transport: wsTransport, | ||
| inputParameters: inputParameters, | ||
| requestTransforms: [ | ||
| (req) => { | ||
| const [ticker, market] = req.requestContext.data.base.split('_') | ||
| if (!ticker || !market) { | ||
| /// Not using customInputValidation because we want to validate after overrides are applied | ||
| throw new AdapterInputError({ | ||
| statusCode: 400, | ||
| message: 'base must be in the format of ${TICKER}_${MARKET}', | ||
| }) | ||
| } | ||
| req.requestContext.data.rawEndpoint = req.requestContext.requestEndpointName | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this? Is the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to inject the raw endpoint into the request itself because we need it to match the cache key when we generate the response from ws messages. Another way to do it would be to modify the 1 request comes in, it will be able to generate two cache entries, the two cache entry need to have different cache key, so we need something more than the request itself to distinguish (here adding a new param into request) |
||
| }, | ||
| ], | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { makeLogger } from '@chainlink/external-adapter-framework/util' | ||
| import { PriceMessage } from './stock' | ||
|
|
||
| const MESSAGE_TTL_SECONDS = 300 | ||
| const convertTimeToMs = (time?: number) => (time ? Math.floor(time * 1000) : undefined) | ||
| const isMessageOld = (time?: number) => | ||
| time ? Date.now() - time > MESSAGE_TTL_SECONDS * 1000 : false | ||
|
|
||
| export class StockCache { | ||
| bidCache: Map<string, { price: number; volume: number; time?: number }> = new Map() | ||
| askCache: Map<string, { price: number; volume: number; time?: number }> = new Map() | ||
| private logger = makeLogger('StockCache') | ||
|
|
||
| processBidAsk(streamId: string, bid?: PriceMessage, ask?: PriceMessage) { | ||
| this.setBookSide(streamId, 'bid', bid) | ||
| this.setBookSide(streamId, 'ask', ask) | ||
| } | ||
|
|
||
| private setBookSide(streamId: string, side: 'bid' | 'ask', msg?: PriceMessage) { | ||
| const cache = side === 'bid' ? this.bidCache : this.askCache | ||
| const price = msg?.value | ||
| const volume = msg?.size | ||
| const time = convertTimeToMs(msg?.unixTimestamp) | ||
|
|
||
| if (isMessageOld(time)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it matter how old it is? If there are no newer messages it's still the most recent data, isn't it?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think here we are preventing server from re-playing an old message, we don't have anything like this in our other ws. This comes from SA as Can remove it if we think this is not important |
||
| this.logger.warn( | ||
| `${side} message ${JSON.stringify(msg)} is more than ${MESSAGE_TTL_SECONDS}s old`, | ||
| ) | ||
| return | ||
| } | ||
| if ( | ||
| price === undefined || | ||
| Number.isNaN(price) || | ||
| volume === undefined || | ||
| Number.isNaN(volume) | ||
| ) { | ||
| this.logger.warn(`Invalid or missing ${side} ${JSON.stringify(msg)}`) | ||
| return | ||
| } | ||
| cache.set(streamId, { | ||
| price, | ||
| volume, | ||
| time, | ||
| }) | ||
| } | ||
|
|
||
| getPriceResponse(streamId: string, last?: PriceMessage) { | ||
| const result = last?.value | ||
| if (result === undefined || Number.isNaN(result)) { | ||
| this.logger.info(`Invalid or missing last: ${JSON.stringify(last)}`) | ||
| return [] | ||
| } | ||
|
|
||
| const time = convertTimeToMs(last?.unixTimestamp) | ||
| if (isMessageOld(time)) { | ||
| this.logger.warn( | ||
| `Last message ${JSON.stringify(last)} is more than ${MESSAGE_TTL_SECONDS}s old`, | ||
| ) | ||
| return [] | ||
| } | ||
|
|
||
| return [ | ||
| { | ||
| params: { base: streamId, rawEndpoint: 'stock' }, | ||
| response: { | ||
| result, | ||
| data: { | ||
| result, | ||
| }, | ||
| ...(time && { | ||
| timestamps: { | ||
| providerIndicatedTimeUnixMs: time, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| ] | ||
| } | ||
|
|
||
| getBidAskResponse(streamId: string) { | ||
| const bid = this.bidCache.get(streamId) | ||
| const ask = this.askCache.get(streamId) | ||
|
|
||
| if (bid && ask) { | ||
| let midPrice: number | ||
| if (bid.price === 0) { | ||
| midPrice = ask.price | ||
| } else if (ask.price === 0) { | ||
| midPrice = bid.price | ||
| } else { | ||
| midPrice = (bid.price + ask.price) / 2 | ||
| } | ||
|
|
||
| const time = Math.max(bid.time || 0, ask.time || 0) | ||
| return [ | ||
| { | ||
| params: { base: streamId, rawEndpoint: 'stock_quotes' }, | ||
| response: { | ||
| result: null, | ||
| data: { | ||
| mid_price: midPrice, | ||
| bid_price: bid.price, | ||
| bid_volume: bid.volume, | ||
| ask_price: ask.price, | ||
| ask_volume: ask.volume, | ||
| }, | ||
| ...(time && { | ||
| timestamps: { | ||
| providerIndicatedTimeUnixMs: time, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| ] | ||
| } else { | ||
| this.logger.info(`Missing bid or ask for ${streamId}`) | ||
| return [] | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.