Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
e807511
Initial commit for split
chray-zhang Sep 4, 2025
638bf65
dependencies
chray-zhang Sep 4, 2025
620f1ff
Changeset
chray-zhang Sep 4, 2025
73628d2
comma
chray-zhang Sep 4, 2025
7d6bd04
Added transport and endpoint
chray-zhang Sep 4, 2025
6129575
cleanup
chray-zhang Sep 4, 2025
c4452fd
cleanup
chray-zhang Sep 4, 2025
b631b7f
Revision
chray-zhang Sep 4, 2025
a53b80f
Removed unused imports
chray-zhang Sep 4, 2025
a8cef07
Revision for comments
chray-zhang Sep 5, 2025
7fb0283
Removed unused import
chray-zhang Sep 5, 2025
c023388
Merge branch 'ftse-utils' into ftse-transport-endpoint-v2
chray-zhang Sep 5, 2025
6cb34a0
Removed blacklist
chray-zhang Sep 5, 2025
db45779
Merge branch 'main' into ftse-transport-endpoint-v2
chray-zhang Sep 11, 2025
8feab27
Revision
chray-zhang Sep 12, 2025
51bc4ef
Removed env file
chray-zhang Sep 12, 2025
d8b14c7
Revision
chray-zhang Sep 12, 2025
350dcc4
Removed blacklist
chray-zhang Sep 12, 2025
fc20d86
removed transport test
chray-zhang Sep 12, 2025
d4d129c
removed unused transport snap
chray-zhang Sep 12, 2025
b3dafb6
Fix lint
chray-zhang Sep 12, 2025
ecaf34c
Removed download
chray-zhang Sep 12, 2025
af027e9
Added back file name and path
chray-zhang Sep 12, 2025
0fe3e7a
Added back blacklist
chray-zhang Sep 12, 2025
8602bbb
Fix lint
chray-zhang Sep 12, 2025
6b09c27
Revision
chray-zhang Sep 15, 2025
cc89634
renamed utils to constants
chray-zhang Sep 15, 2025
9e0a4ff
Revision
chray-zhang Sep 15, 2025
81592f5
revision
chray-zhang Sep 15, 2025
bd2db03
Revision
chray-zhang Sep 15, 2025
9df9f57
Fixed any
chray-zhang Sep 15, 2025
dde1f42
Fixed Types
chray-zhang Sep 15, 2025
28cc03f
Added Adapter to pass deploy test
chray-zhang Sep 15, 2025
9911e27
Empty index.ts to pass tests
chray-zhang Sep 15, 2025
b9af068
index.ts
chray-zhang Sep 15, 2025
4e946b0
Revert "index.ts"
chray-zhang Sep 16, 2025
b3419ea
Revert "Empty index.ts to pass tests"
chray-zhang Sep 16, 2025
e3d6eaf
Revert "Added Adapter to pass deploy test"
chray-zhang Sep 16, 2025
0b87eac
Merge branch 'main' into ftse-transport-endpoint-v2
chray-zhang Sep 16, 2025
fea6694
Initial commit for download
chray-zhang Sep 16, 2025
ecd2712
[NOISSUE] Adding index.ts to ftse sftp EA to pass deploy workflow (#4…
chray-zhang Sep 15, 2025
a0dc7cb
Added download logic and tests
chray-zhang Sep 17, 2025
873e061
Changesets
chray-zhang Sep 17, 2025
bf673fa
Types
chray-zhang Sep 17, 2025
411d5ed
Types
chray-zhang Sep 17, 2025
0552985
removed all any
chray-zhang Sep 17, 2025
2915326
cleaner validation
chray-zhang Sep 17, 2025
189e150
Merge branch 'ftse-transport-endpoint-v2' into full-ea-sftp
chray-zhang Sep 17, 2025
d9597d3
Merge branch 'main' into full-ea-sftp
chray-zhang Sep 17, 2025
9a8ca73
Fixed Lint
chray-zhang Sep 17, 2025
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/warm-hornets-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/ftse-sftp-adapter': major
---

Adding Downloading and parsing logic for russell and ftse csv files from ftse sftp server
13 changes: 12 additions & 1 deletion packages/sources/ftse-sftp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
export * from './parsing'
import { expose, ServerInstance } from '@chainlink/external-adapter-framework'
import { Adapter } from '@chainlink/external-adapter-framework/adapter'
import { config } from './config'
import * as endpoints from './endpoint'

export const adapter = new Adapter({
name: 'FTSE_SFTP',
config,
endpoints: [endpoints.sftp.endpoint],
})

export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
29 changes: 29 additions & 0 deletions packages/sources/ftse-sftp/src/transport/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const instrumentToFilePathMap: Record<string, string> = {
FTSE100INDEX: '/data/valuation/uk_all_share/',
Russell1000INDEX: '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/',
Russell2000INDEX: '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/',
Russell3000INDEX: '/data/Returns_and_Values/Russell_US_Indexes_Daily_Index_Values_Real_Time_TXT/',
}

export const instrumentToFileTemplateMap: Record<string, string> = {
FTSE100INDEX: 'ukallv*.csv',
Russell1000INDEX: 'daily_values_russell_*.CSV',
Russell2000INDEX: 'daily_values_russell_*.CSV',
Russell3000INDEX: 'daily_values_russell_*.CSV',
}

export const instrumentToFileRegexMap: Record<string, RegExp> = {
FTSE100INDEX: /^ukallv\d{4}\.csv$/,
Russell1000INDEX: /^daily_values_russell_\d{6}\.CSV$/,
Russell2000INDEX: /^daily_values_russell_\d{6}\.CSV$/,
Russell3000INDEX: /^daily_values_russell_\d{6}\.CSV$/,
}

/**
* Validates if an instrument is supported by checking if it has all required mappings
* @param instrument The instrument identifier to validate
* @returns true if the instrument is supported, false otherwise
*/
export function isInstrumentSupported(instrument: string): boolean {
return !!(instrumentToFilePathMap[instrument] && instrumentToFileRegexMap[instrument])
}
174 changes: 170 additions & 4 deletions packages/sources/ftse-sftp/src/transport/sftp.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { EndpointContext } from '@chainlink/external-adapter-framework/adapter'
import { ResponseCache } from '@chainlink/external-adapter-framework/cache/response'
import { TransportDependencies } from '@chainlink/external-adapter-framework/transports'
import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription'
import { sleep } from '@chainlink/external-adapter-framework/util'
import SftpClient from 'ssh2-sftp-client'
import { BaseEndpointTypes } from '../endpoint/sftp'
import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util'
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
import SftpClient, { FileInfo } from 'ssh2-sftp-client'
import { BaseEndpointTypes, IndexResponseData, inputParameters } from '../endpoint/sftp'
import { CSVParserFactory } from '../parsing/factory'
import {
instrumentToFilePathMap,
instrumentToFileRegexMap,
isInstrumentSupported,
} from './constants'

const logger = makeLogger('FTSE SFTP Adapter')

type RequestParams = typeof inputParameters.validated

interface SftpConnectionConfig {
host: string
port: number
username: string
password: string
readyTimeout: number
}

export class SftpTransport extends SubscriptionTransport<BaseEndpointTypes> {
config!: BaseEndpointTypes['Settings']
endpointName!: string
name!: string
responseCache!: ResponseCache<BaseEndpointTypes>
sftpClient: SftpClient

constructor() {
Expand All @@ -24,12 +46,156 @@ export class SftpTransport extends SubscriptionTransport<BaseEndpointTypes> {
await super.initialize(dependencies, adapterSettings, endpointName, transportName)
this.config = adapterSettings
this.endpointName = endpointName
this.name = transportName
this.responseCache = dependencies.responseCache
}

async backgroundHandler(context: EndpointContext<BaseEndpointTypes>): Promise<void> {
async backgroundHandler(
context: EndpointContext<BaseEndpointTypes>,
entries: RequestParams[],
): Promise<void> {
await Promise.all(entries.map(async (param) => this.handleRequest(param)))
await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
}

async handleRequest(param: RequestParams) {
let response: AdapterResponse<BaseEndpointTypes['Response']>
try {
response = await this._handleRequest(param)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred'
response = {
statusCode: 502,
errorMessage,
timestamps: {
providerDataRequestedUnixMs: 0,
providerDataReceivedUnixMs: 0,
providerIndicatedTimeUnixMs: undefined,
},
}
} finally {
try {
await this.sftpClient.end()
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We need to close the client when we're done or else we'll run into end Event raised for a hanging sftp connection
Screenshot 2025-09-16 at 11 45 18 PM

logger.info('SFTP connection closed')
} catch (error) {
logger.error('Error closing SFTP connection:', error)
}
}

await this.responseCache.write(this.name, [{ params: param, response }])
}

async _handleRequest(
param: RequestParams,
): Promise<AdapterResponse<BaseEndpointTypes['Response']>> {
const providerDataRequestedUnixMs = Date.now()

await this.connectToSftp()

const parsedData = await this.tryDownloadAndParseFile(param.instrument)

// Extract the numeric result based on the data type
let result: number
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

According to ResponseGeneric we need some sort of result

if ('gbpIndex' in parsedData) {
// FTSE data
result = (parsedData.gbpIndex as number) ?? 0
} else if ('close' in parsedData) {
// Russell data
result = parsedData.close as number
} else {
throw new Error('Unknown data format received from parser')
}

logger.info(`Successfully processed data for instrument: ${param.instrument}`)
return {
data: {
result: parsedData,
},
statusCode: 200,
result,
timestamps: {
providerDataRequestedUnixMs,
providerDataReceivedUnixMs: Date.now(),
providerIndicatedTimeUnixMs: undefined,
},
}
}

private async connectToSftp(): Promise<void> {
const connectConfig: SftpConnectionConfig = {
host: this.config.SFTP_HOST,
port: this.config.SFTP_PORT || 22,
username: this.config.SFTP_USERNAME,
password: this.config.SFTP_PASSWORD,
readyTimeout: 30000,
}

try {
// Create a new client instance to avoid connection state issues
this.sftpClient = new SftpClient()
await this.sftpClient.connect(connectConfig)
logger.info('Successfully connected to SFTP server')
} catch (error) {
logger.error(error, 'Failed to connect to SFTP server')
throw new AdapterInputError({
statusCode: 500,
message: `Failed to connect to SFTP server: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
})
}
}

private async tryDownloadAndParseFile(instrument: string): Promise<IndexResponseData> {
// Validate that the instrument is supported
if (!isInstrumentSupported(instrument)) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Maybe I should move this validation up to handleRequest?

throw new AdapterInputError({
statusCode: 400,
message: `Unsupported instrument: ${instrument}`,
})
}

const filePath = instrumentToFilePathMap[instrument]
const fileRegex = instrumentToFileRegexMap[instrument]

const fileList = await this.sftpClient.list(filePath)
// Filter files based on the regex pattern
const matchingFiles = fileList
.map((file: FileInfo) => file.name)
.filter((fileName: string) => fileRegex.test(fileName))

if (matchingFiles.length === 0) {
throw new AdapterInputError({
statusCode: 500,
message: `No files matching pattern ${fileRegex} found in directory: ${filePath}`,
})
} else if (matchingFiles.length > 1) {
throw new AdapterInputError({
statusCode: 500,
message: `Multiple files matching pattern ${fileRegex} found in directory: ${filePath}.`,
})
}
const fullPath = `${filePath}${matchingFiles[0]}`

// Log the download attempt
logger.info(`Downloading file: ${fullPath}`)

const fileContent = await this.sftpClient.get(fullPath)
// we need latin1 here because the file contains special characters like "®"
const csvContent = fileContent.toString('latin1')

const parser = CSVParserFactory.detectParserByInstrument(instrument)

if (!parser) {
throw new AdapterInputError({
statusCode: 500,
message: `Parser initialization failed for instrument: ${instrument}`,
})
}

return (await parser.parse(csvContent)) as IndexResponseData
}

getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number {
return adapterSettings.BACKGROUND_EXECUTE_MS || 60000
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`execute ftse_sftp endpoint should return success for FTSE100INDEX 1`] = `
{
"data": {
"result": {
"gbpIndex": 8045.12345678,
"indexBaseCurrency": "GBP",
"indexCode": "UKX",
"indexSectorName": "FTSE 100 Index",
"numberOfConstituents": 100,
},
},
"result": 8045.12345678,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 1641035471111,
"providerDataRequestedUnixMs": 1641035471111,
},
}
`;

exports[`execute ftse_sftp endpoint should return success for Russell1000INDEX 1`] = `
{
"data": {
"result": {
"close": 2654.123456,
"indexName": "Russell 1000® Index",
},
},
"result": 2654.123456,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 1641035471111,
"providerDataRequestedUnixMs": 1641035471111,
},
}
`;

exports[`execute ftse_sftp endpoint should return success for Russell2000INDEX 1`] = `
{
"data": {
"result": {
"close": 1987.654321,
"indexName": "Russell 2000® Index",
},
},
"result": 1987.654321,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 1641035471111,
"providerDataRequestedUnixMs": 1641035471111,
},
}
`;

exports[`execute ftse_sftp endpoint should return success for Russell3000INDEX 1`] = `
{
"data": {
"result": {
"close": 3456.789012,
"indexName": "Russell 3000® Index",
},
},
"result": 3456.789012,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 1641035471111,
"providerDataRequestedUnixMs": 1641035471111,
},
}
`;
Loading
Loading