Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/true-sides-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/six-adapter': patch
Comment thread
mxiao-cll marked this conversation as resolved.
---

Price endpoint
9 changes: 9 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
1 change: 1 addition & 0 deletions packages/sources/six/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"start": "yarn server:dist"
},
"devDependencies": {
"@sinonjs/fake-timers": "15.4.0",
"@types/jest": "^29.5.14",
"@types/node": "22.14.1",
"nock": "13.5.6",
Expand Down
121 changes: 65 additions & 56 deletions packages/sources/six/src/config/index.ts
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,
},
},
})
)
1 change: 1 addition & 0 deletions packages/sources/six/src/endpoint/index.ts
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'
60 changes: 60 additions & 0 deletions packages/sources/six/src/endpoint/stock.ts
Comment thread
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: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? Is the requestContext not also available in the transport?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 base itself to include the endpoint names


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)

},
],
})
6 changes: 3 additions & 3 deletions packages/sources/six/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { expose, ServerInstance } from '@chainlink/external-adapter-framework'
import { Adapter } from '@chainlink/external-adapter-framework/adapter'
import { config } from './config'
import { marketStatus } from './endpoint'
import { marketStatus, stock } from './endpoint'

export const adapter = new Adapter({
defaultEndpoint: marketStatus.name,
defaultEndpoint: stock.name,
name: 'SIX',
config,
endpoints: [marketStatus],
endpoints: [marketStatus, stock],
rateLimiting: {
tiers: {
default: {
Expand Down
120 changes: 120 additions & 0 deletions packages/sources/six/src/transport/stock-cache.ts
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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

it is not a client or product’s requirement. we added it as a security measure to avoid writing stale data. if this is already managed elsewhere, feel free to remove that business logic from the adapter.

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 []
}
}
}
Loading
Loading