diff --git a/package.json b/package.json index 8661982f..bcb45310 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "pino-pretty": "13.1.2", "prom-client": "15.1.3", "redlock": "5.0.0-beta.2", - "ws": "8.18.3" + "ws": "8.18.3", + "@date-fns/tz": "1.4.1" }, "scripts": { "build": "rm -rf dist/src && mkdir -p ./dist/src && cp package.json dist/src && cp README.md dist/src && tsc && yarn pre-build-generator", diff --git a/src/adapter/market-status.ts b/src/adapter/market-status.ts index ecd7fdaf..4373af94 100644 --- a/src/adapter/market-status.ts +++ b/src/adapter/market-status.ts @@ -1,5 +1,8 @@ import { TransportGenerics } from '../transports' import { AdapterEndpoint } from './endpoint' +import { AdapterEndpointParams } from './types' +import { parseWeekendString } from '../validation/market-status' +import { AdapterInputError } from '../validation/error' /** * Base input parameter config that any [[MarketStatusEndpoint]] must extend @@ -17,6 +20,11 @@ export const marketStatusEndpointInputParametersDefinition = { options: ['regular', '24/5'], default: 'regular', }, + weekend: { + type: 'string', + description: + 'DHH-DHH:TZ, 520-020:America/New_York means Fri 20:00 to Sun 20:00 Eastern Time Zone', + }, } as const export enum MarketStatus { @@ -58,4 +66,21 @@ export type MarketStatusEndpointGenerics = TransportGenerics & { */ export class MarketStatusEndpoint< T extends MarketStatusEndpointGenerics, -> extends AdapterEndpoint {} +> extends AdapterEndpoint { + constructor(params: AdapterEndpointParams) { + params.customInputValidation = (req, _adapterSettings) => { + const data = req.requestContext.data as Record + if (data['type'] === '24/5') { + parseWeekendString(data['weekend']) + } + if (data['type'] === 'regular' && data['weekend']) { + throw new AdapterInputError({ + statusCode: 400, + message: '[Param: weekend] must be empty when [Param: type] is regular', + }) + } + return undefined + } + super(params) + } +} diff --git a/src/validation/market-status.ts b/src/validation/market-status.ts new file mode 100644 index 00000000..f1dd6d2d --- /dev/null +++ b/src/validation/market-status.ts @@ -0,0 +1,68 @@ +import { AdapterInputError } from '../validation/error' +import { TZDate } from '@date-fns/tz' + +export const parseWeekendString = (weekend?: string) => { + const dayHour = /[0-6](0\d|1\d|2[0-3])/ + const timezonePattern = /[^\s]+/ + const regex = new RegExp(`^(${dayHour.source})-(${dayHour.source}):(${timezonePattern.source})$`) + + const match = weekend?.match(regex) + if (!match) { + throw new AdapterInputError({ + statusCode: 400, + message: '[Param: weekend] does not match format of DHH-DHH:TZ', + }) + } + + const result = { + start: match[1], + end: match[3], + tz: match[5], + } + + try { + // eslint-disable-next-line new-cap + Intl.DateTimeFormat(undefined, { timeZone: result.tz }) + } catch (error) { + throw new AdapterInputError({ + statusCode: 400, + message: `timezone ${result.tz} in [Param: weekend] is not valid: ${error}`, + }) + } + + return result +} + +export const isWeekendNow = (weekend?: string) => { + const parsed = parseWeekendString(weekend) + + const startDay = Number(parsed.start[0]) + const startHour = Number(parsed.start.slice(1)) + const endDay = Number(parsed.end[0]) + const endHour = Number(parsed.end.slice(1)) + + const nowDay = TZDate.tz(parsed.tz).getDay() + const nowHour = TZDate.tz(parsed.tz).getHours() + + // Case 1: weekend does NOT wrap around the week + if (startDay < endDay || (startDay === endDay && startHour < endHour)) { + if (nowDay < startDay || nowDay > endDay) { + return false + } else if (nowDay === startDay && nowHour < startHour) { + return false + } else if (nowDay === endDay && nowHour >= endHour) { + return false + } + return true + } + + // Case 2: weekend wraps around (e.g. Fri → Sun) + if (nowDay > startDay || nowDay < endDay) { + return true + } else if (nowDay === startDay && nowHour >= startHour) { + return true + } else if (nowDay === endDay && nowHour < endHour) { + return true + } + return false +} diff --git a/test/adapter/market-status.test.ts b/test/adapter/market-status.test.ts new file mode 100644 index 00000000..bf992157 --- /dev/null +++ b/test/adapter/market-status.test.ts @@ -0,0 +1,95 @@ +import test from 'ava' +import '../../src/adapter' +import { + MarketStatusEndpoint, + marketStatusEndpointInputParametersDefinition, + MarketStatusEndpointGenerics, +} from '../../src/adapter/market-status' +import { InputParameters } from '../../src/validation' +import { TestAdapter } from '../../src/util/testing-utils' +import { Adapter } from '../../src/adapter/basic' + +import { Transport } from '../../src/transports' +import { ResponseCache } from '../../src/cache/response' + +test('MarketStatusEndpoint - validates weekend', async (t) => { + class MarketStatusTestTransport implements Transport { + name!: string + responseCache!: ResponseCache + + async initialize() {} + + async foregroundExecute() { + return { + data: { + result: 2, + statusString: 'OPEN', + }, + result: 2, + statusCode: 200, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: 0, + }, + } + } + } + + const adapter = new Adapter({ + name: 'TEST', + endpoints: [ + new MarketStatusEndpoint({ + name: 'test', + inputParameters: new InputParameters(marketStatusEndpointInputParametersDefinition), + transport: new MarketStatusTestTransport(), + }), + ], + }) + + const testAdapter = await TestAdapter.start( + adapter, + {} as { + testAdapter: TestAdapter + }, + ) + + const response1 = await testAdapter.request({ + market: 'BTC', + type: 'regular', + endpoint: 'test', + }) + t.is(response1.statusCode, 200, 'Should succeed with empty weekend when type is regular') + + const response2 = await testAdapter.request({ + market: 'BTC', + type: 'regular', + weekend: '520-020', + endpoint: 'test', + }) + t.is(response2.statusCode, 400, 'Should fail with weekend when type is regular') + t.true( + response2 + .json() + .error.message.includes('[Param: weekend] must be empty when [Param: type] is regular'), + ) + + const response3 = await testAdapter.request({ + market: 'BTC', + type: '24/5', + weekend: '520-020', + endpoint: 'test', + }) + t.is(response3.statusCode, 400, 'Should fail with invalid weekend format when type is 24/5') + t.true(response3.json().error.message.includes('[Param: weekend] does not match format')) + + const response4 = await testAdapter.request({ + market: 'BTC', + type: '24/5', + weekend: '520-020:America/New_York', + endpoint: 'test', + }) + t.is(response4.statusCode, 200, 'Should succeed with valid weekend when type is 24/5') + + await testAdapter.api.close() +}) diff --git a/test/validation/market-status.test.ts b/test/validation/market-status.test.ts new file mode 100644 index 00000000..5e9f3adc --- /dev/null +++ b/test/validation/market-status.test.ts @@ -0,0 +1,146 @@ +import test from 'ava' +import FakeTimers from '@sinonjs/fake-timers' +import { parseWeekendString, isWeekendNow } from '../../src/validation/market-status' + +test('parseWeekendString - success', (t) => { + t.notThrows(() => { + parseWeekendString('520-020:America/New_York') + parseWeekendString('000-123:UTC') + parseWeekendString('123-423:Europe/London') + parseWeekendString('600-023:Asia/Tokyo') + }) +}) + +test('parseWeekendString - bad format', (t) => { + t.throws(() => { + parseWeekendString('520020:America/New_York') + }) + t.throws(() => { + parseWeekendString('520-020America/New_York') + }) + t.throws(() => { + parseWeekendString('520-020:') + }) + t.throws(() => { + parseWeekendString('55-020:UTC') + }) + t.throws(() => { + parseWeekendString('55-20:UTC') + }) + t.throws(() => { + parseWeekendString('') + }) + t.throws(() => { + parseWeekendString() + }) + t.throws(() => { + parseWeekendString('520:020-America/New_York') + }) + t.throws(() => { + parseWeekendString('520-020: ') + }) +}) + +test('parseWeekendString - bad number', (t) => { + t.throws(() => { + parseWeekendString('720-020:UTC') + }) + t.throws(() => { + parseWeekendString('524-020:UTC') + }) + t.throws(() => { + parseWeekendString('525-020:UTC') + }) +}) + +test('parseWeekendString - invalid timezone', (t) => { + t.throws(() => { + parseWeekendString('520-020:Invalid/Timezone') + parseWeekendString('520-020:AmericaNew_York') + }) +}) + +const clock = FakeTimers.install({ toFake: ['Date'] }) + +test.after(() => { + clock.uninstall() +}) + +test('isWeekendNow - UTC', (t) => { + // Saturday 12:00 -> 612 + clock.setSystemTime(new Date('2024-01-06T12:00:00Z').getTime()) + + t.false(isWeekendNow('000-123:UTC'), 'Before start day') + t.false(isWeekendNow('400-500:UTC'), 'After end day') + t.false(isWeekendNow('613-620:UTC'), 'Before start hour') + t.false(isWeekendNow('610-612:UTC'), 'After end hour') + + t.true(isWeekendNow('610-620:UTC'), 'Non-wrapping: middle of weekend should return true') + t.true(isWeekendNow('600-023:UTC'), 'Non-wrapping: spanning multiple days should return true') + t.true(isWeekendNow('612-615:UTC'), 'Non-wrapping: same day, at start hour should return true') + + t.true(isWeekendNow('520-020:UTC'), 'Wrapping: nowDay > startDay should return true') + t.true(isWeekendNow('400-200:UTC'), 'Wrapping: nowDay > startDay should return true') + + t.true(isWeekendNow('612-020:UTC'), 'After start hour') + t.true(isWeekendNow('520-613:UTC'), 'Before end hour') + + t.false( + isWeekendNow('620-610:UTC'), + 'Wrapping same day: between end and start should return false', + ) +}) + +test('isWeekendNow - ET', (t) => { + // Saturday 12:00 UTC = Saturday 07:00 EST -> 607 + clock.setSystemTime(new Date('2024-01-06T12:00:00Z').getTime()) + + t.false(isWeekendNow('000-123:America/New_York'), 'Before start day') + t.false(isWeekendNow('400-500:America/New_York'), 'After end day') + t.false(isWeekendNow('608-620:America/New_York'), 'Before start hour') + t.false(isWeekendNow('605-607:America/New_York'), 'After end hour') + + t.true(isWeekendNow('605-620:America/New_York'), 'Non-wrapping: middle of weekend') + t.true(isWeekendNow('600-023:America/New_York'), 'Non-wrapping: spanning multiple days') + t.true(isWeekendNow('607-610:America/New_York'), 'Non-wrapping: same day, at start hour ') + + t.true(isWeekendNow('520-020:America/New_York'), 'Wrapping: nowDay > startDay should return true') + t.true(isWeekendNow('400-200:America/New_York'), 'Wrapping: nowDay > startDay should return true') + + t.true(isWeekendNow('607-020:America/New_York'), 'After start hour') + t.true(isWeekendNow('520-608:America/New_York'), 'Before end hour') + + t.false(isWeekendNow('620-607:America/New_York'), 'Wrapping same day: at end hour should') +}) + +test('isWeekendNow - ET - Fri to Sun 8 to 8', (t) => { + // Weekend: Fri 20:00 to Sun 20:00 ET (520-020:America/New_York) + const range = '520-020:America/New_York' + // Thu 21:00 ET + clock.setSystemTime(new Date('2024-01-05T02:00:00Z').getTime()) + t.false(isWeekendNow(range), 'Before start day') + // Fri 19:00 ET + clock.setSystemTime(new Date('2024-01-06T00:00:00Z').getTime()) + t.false(isWeekendNow(range), 'On start day, before start hour') + // Fri 20:00 ET + clock.setSystemTime(new Date('2024-01-06T01:00:00Z').getTime()) + t.true(isWeekendNow(range), 'On start day, at start hour') + // Fri 23:00 ET + clock.setSystemTime(new Date('2024-01-06T04:00:00Z').getTime()) + t.true(isWeekendNow(range), 'On start day, after start hour') + // Sat 12:00 ET + clock.setSystemTime(new Date('2024-01-06T17:00:00Z').getTime()) + t.true(isWeekendNow(range), 'Middle day (Saturday)') + // Sun 19:00 ET + clock.setSystemTime(new Date('2024-01-08T00:00:00Z').getTime()) + t.true(isWeekendNow(range), 'On end day, before end hour') + // Sun 20:00 ET + clock.setSystemTime(new Date('2024-01-08T01:00:00Z').getTime()) + t.false(isWeekendNow(range), 'On end day, at end hour') + // Sun 21:00 ET + clock.setSystemTime(new Date('2024-01-08T02:00:00Z').getTime()) + t.false(isWeekendNow(range), 'On end day, after end hour') + // Mon 10:00 ET + clock.setSystemTime(new Date('2024-01-08T15:00:00Z').getTime()) + t.false(isWeekendNow(range), 'After end day') +}) diff --git a/yarn.lock b/yarn.lock index 31f34b36..954b0774 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,6 +14,11 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@date-fns/tz@1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4" + integrity sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA== + "@eslint-community/eslint-utils@^4.7.0": version "4.7.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"