Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion src/adapter/market-status.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -58,4 +66,21 @@ export type MarketStatusEndpointGenerics = TransportGenerics & {
*/
export class MarketStatusEndpoint<
T extends MarketStatusEndpointGenerics,
> extends AdapterEndpoint<T> {}
> extends AdapterEndpoint<T> {
constructor(params: AdapterEndpointParams<T>) {
params.customInputValidation = (req, _adapterSettings) => {
const data = req.requestContext.data as Record<string, string>
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)
}
}
68 changes: 68 additions & 0 deletions src/validation/market-status.ts
Original file line number Diff line number Diff line change
@@ -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
}
95 changes: 95 additions & 0 deletions test/adapter/market-status.test.ts
Original file line number Diff line number Diff line change
@@ -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<MarketStatusEndpointGenerics> {
name!: string
responseCache!: ResponseCache<MarketStatusEndpointGenerics>

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()
})
146 changes: 146 additions & 0 deletions test/validation/market-status.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading