Skip to content

Commit 7550a45

Browse files
authored
Add optional weekend into market status (#622)
* Add optional weekend into market status * Address comments
1 parent 91c62a2 commit 7550a45

6 files changed

Lines changed: 342 additions & 2 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"pino-pretty": "13.1.2",
1616
"prom-client": "15.1.3",
1717
"redlock": "5.0.0-beta.2",
18-
"ws": "8.18.3"
18+
"ws": "8.18.3",
19+
"@date-fns/tz": "1.4.1"
1920
},
2021
"scripts": {
2122
"build": "rm -rf dist/src && mkdir -p ./dist/src && cp package.json dist/src && cp README.md dist/src && tsc && yarn pre-build-generator",

src/adapter/market-status.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { TransportGenerics } from '../transports'
22
import { AdapterEndpoint } from './endpoint'
3+
import { AdapterEndpointParams } from './types'
4+
import { parseWeekendString } from '../validation/market-status'
5+
import { AdapterInputError } from '../validation/error'
36

47
/**
58
* Base input parameter config that any [[MarketStatusEndpoint]] must extend
@@ -17,6 +20,11 @@ export const marketStatusEndpointInputParametersDefinition = {
1720
options: ['regular', '24/5'],
1821
default: 'regular',
1922
},
23+
weekend: {
24+
type: 'string',
25+
description:
26+
'DHH-DHH:TZ, 520-020:America/New_York means Fri 20:00 to Sun 20:00 Eastern Time Zone',
27+
},
2028
} as const
2129

2230
export enum MarketStatus {
@@ -58,4 +66,21 @@ export type MarketStatusEndpointGenerics = TransportGenerics & {
5866
*/
5967
export class MarketStatusEndpoint<
6068
T extends MarketStatusEndpointGenerics,
61-
> extends AdapterEndpoint<T> {}
69+
> extends AdapterEndpoint<T> {
70+
constructor(params: AdapterEndpointParams<T>) {
71+
params.customInputValidation = (req, _adapterSettings) => {
72+
const data = req.requestContext.data as Record<string, string>
73+
if (data['type'] === '24/5') {
74+
parseWeekendString(data['weekend'])
75+
}
76+
if (data['type'] === 'regular' && data['weekend']) {
77+
throw new AdapterInputError({
78+
statusCode: 400,
79+
message: '[Param: weekend] must be empty when [Param: type] is regular',
80+
})
81+
}
82+
return undefined
83+
}
84+
super(params)
85+
}
86+
}

src/validation/market-status.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { AdapterInputError } from '../validation/error'
2+
import { TZDate } from '@date-fns/tz'
3+
4+
export const parseWeekendString = (weekend?: string) => {
5+
const dayHour = /[0-6](0\d|1\d|2[0-3])/
6+
const timezonePattern = /[^\s]+/
7+
const regex = new RegExp(`^(${dayHour.source})-(${dayHour.source}):(${timezonePattern.source})$`)
8+
9+
const match = weekend?.match(regex)
10+
if (!match) {
11+
throw new AdapterInputError({
12+
statusCode: 400,
13+
message: '[Param: weekend] does not match format of DHH-DHH:TZ',
14+
})
15+
}
16+
17+
const result = {
18+
start: match[1],
19+
end: match[3],
20+
tz: match[5],
21+
}
22+
23+
try {
24+
// eslint-disable-next-line new-cap
25+
Intl.DateTimeFormat(undefined, { timeZone: result.tz })
26+
} catch (error) {
27+
throw new AdapterInputError({
28+
statusCode: 400,
29+
message: `timezone ${result.tz} in [Param: weekend] is not valid: ${error}`,
30+
})
31+
}
32+
33+
return result
34+
}
35+
36+
export const isWeekendNow = (weekend?: string) => {
37+
const parsed = parseWeekendString(weekend)
38+
39+
const startDay = Number(parsed.start[0])
40+
const startHour = Number(parsed.start.slice(1))
41+
const endDay = Number(parsed.end[0])
42+
const endHour = Number(parsed.end.slice(1))
43+
44+
const nowDay = TZDate.tz(parsed.tz).getDay()
45+
const nowHour = TZDate.tz(parsed.tz).getHours()
46+
47+
// Case 1: weekend does NOT wrap around the week
48+
if (startDay < endDay || (startDay === endDay && startHour < endHour)) {
49+
if (nowDay < startDay || nowDay > endDay) {
50+
return false
51+
} else if (nowDay === startDay && nowHour < startHour) {
52+
return false
53+
} else if (nowDay === endDay && nowHour >= endHour) {
54+
return false
55+
}
56+
return true
57+
}
58+
59+
// Case 2: weekend wraps around (e.g. Fri → Sun)
60+
if (nowDay > startDay || nowDay < endDay) {
61+
return true
62+
} else if (nowDay === startDay && nowHour >= startHour) {
63+
return true
64+
} else if (nowDay === endDay && nowHour < endHour) {
65+
return true
66+
}
67+
return false
68+
}

test/adapter/market-status.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import test from 'ava'
2+
import '../../src/adapter'
3+
import {
4+
MarketStatusEndpoint,
5+
marketStatusEndpointInputParametersDefinition,
6+
MarketStatusEndpointGenerics,
7+
} from '../../src/adapter/market-status'
8+
import { InputParameters } from '../../src/validation'
9+
import { TestAdapter } from '../../src/util/testing-utils'
10+
import { Adapter } from '../../src/adapter/basic'
11+
12+
import { Transport } from '../../src/transports'
13+
import { ResponseCache } from '../../src/cache/response'
14+
15+
test('MarketStatusEndpoint - validates weekend', async (t) => {
16+
class MarketStatusTestTransport implements Transport<MarketStatusEndpointGenerics> {
17+
name!: string
18+
responseCache!: ResponseCache<MarketStatusEndpointGenerics>
19+
20+
async initialize() {}
21+
22+
async foregroundExecute() {
23+
return {
24+
data: {
25+
result: 2,
26+
statusString: 'OPEN',
27+
},
28+
result: 2,
29+
statusCode: 200,
30+
timestamps: {
31+
providerDataRequestedUnixMs: 0,
32+
providerDataReceivedUnixMs: 0,
33+
providerIndicatedTimeUnixMs: 0,
34+
},
35+
}
36+
}
37+
}
38+
39+
const adapter = new Adapter({
40+
name: 'TEST',
41+
endpoints: [
42+
new MarketStatusEndpoint({
43+
name: 'test',
44+
inputParameters: new InputParameters(marketStatusEndpointInputParametersDefinition),
45+
transport: new MarketStatusTestTransport(),
46+
}),
47+
],
48+
})
49+
50+
const testAdapter = await TestAdapter.start(
51+
adapter,
52+
{} as {
53+
testAdapter: TestAdapter
54+
},
55+
)
56+
57+
const response1 = await testAdapter.request({
58+
market: 'BTC',
59+
type: 'regular',
60+
endpoint: 'test',
61+
})
62+
t.is(response1.statusCode, 200, 'Should succeed with empty weekend when type is regular')
63+
64+
const response2 = await testAdapter.request({
65+
market: 'BTC',
66+
type: 'regular',
67+
weekend: '520-020',
68+
endpoint: 'test',
69+
})
70+
t.is(response2.statusCode, 400, 'Should fail with weekend when type is regular')
71+
t.true(
72+
response2
73+
.json()
74+
.error.message.includes('[Param: weekend] must be empty when [Param: type] is regular'),
75+
)
76+
77+
const response3 = await testAdapter.request({
78+
market: 'BTC',
79+
type: '24/5',
80+
weekend: '520-020',
81+
endpoint: 'test',
82+
})
83+
t.is(response3.statusCode, 400, 'Should fail with invalid weekend format when type is 24/5')
84+
t.true(response3.json().error.message.includes('[Param: weekend] does not match format'))
85+
86+
const response4 = await testAdapter.request({
87+
market: 'BTC',
88+
type: '24/5',
89+
weekend: '520-020:America/New_York',
90+
endpoint: 'test',
91+
})
92+
t.is(response4.statusCode, 200, 'Should succeed with valid weekend when type is 24/5')
93+
94+
await testAdapter.api.close()
95+
})
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import test from 'ava'
2+
import FakeTimers from '@sinonjs/fake-timers'
3+
import { parseWeekendString, isWeekendNow } from '../../src/validation/market-status'
4+
5+
test('parseWeekendString - success', (t) => {
6+
t.notThrows(() => {
7+
parseWeekendString('520-020:America/New_York')
8+
parseWeekendString('000-123:UTC')
9+
parseWeekendString('123-423:Europe/London')
10+
parseWeekendString('600-023:Asia/Tokyo')
11+
})
12+
})
13+
14+
test('parseWeekendString - bad format', (t) => {
15+
t.throws(() => {
16+
parseWeekendString('520020:America/New_York')
17+
})
18+
t.throws(() => {
19+
parseWeekendString('520-020America/New_York')
20+
})
21+
t.throws(() => {
22+
parseWeekendString('520-020:')
23+
})
24+
t.throws(() => {
25+
parseWeekendString('55-020:UTC')
26+
})
27+
t.throws(() => {
28+
parseWeekendString('55-20:UTC')
29+
})
30+
t.throws(() => {
31+
parseWeekendString('')
32+
})
33+
t.throws(() => {
34+
parseWeekendString()
35+
})
36+
t.throws(() => {
37+
parseWeekendString('520:020-America/New_York')
38+
})
39+
t.throws(() => {
40+
parseWeekendString('520-020: ')
41+
})
42+
})
43+
44+
test('parseWeekendString - bad number', (t) => {
45+
t.throws(() => {
46+
parseWeekendString('720-020:UTC')
47+
})
48+
t.throws(() => {
49+
parseWeekendString('524-020:UTC')
50+
})
51+
t.throws(() => {
52+
parseWeekendString('525-020:UTC')
53+
})
54+
})
55+
56+
test('parseWeekendString - invalid timezone', (t) => {
57+
t.throws(() => {
58+
parseWeekendString('520-020:Invalid/Timezone')
59+
parseWeekendString('520-020:AmericaNew_York')
60+
})
61+
})
62+
63+
const clock = FakeTimers.install({ toFake: ['Date'] })
64+
65+
test.after(() => {
66+
clock.uninstall()
67+
})
68+
69+
test('isWeekendNow - UTC', (t) => {
70+
// Saturday 12:00 -> 612
71+
clock.setSystemTime(new Date('2024-01-06T12:00:00Z').getTime())
72+
73+
t.false(isWeekendNow('000-123:UTC'), 'Before start day')
74+
t.false(isWeekendNow('400-500:UTC'), 'After end day')
75+
t.false(isWeekendNow('613-620:UTC'), 'Before start hour')
76+
t.false(isWeekendNow('610-612:UTC'), 'After end hour')
77+
78+
t.true(isWeekendNow('610-620:UTC'), 'Non-wrapping: middle of weekend should return true')
79+
t.true(isWeekendNow('600-023:UTC'), 'Non-wrapping: spanning multiple days should return true')
80+
t.true(isWeekendNow('612-615:UTC'), 'Non-wrapping: same day, at start hour should return true')
81+
82+
t.true(isWeekendNow('520-020:UTC'), 'Wrapping: nowDay > startDay should return true')
83+
t.true(isWeekendNow('400-200:UTC'), 'Wrapping: nowDay > startDay should return true')
84+
85+
t.true(isWeekendNow('612-020:UTC'), 'After start hour')
86+
t.true(isWeekendNow('520-613:UTC'), 'Before end hour')
87+
88+
t.false(
89+
isWeekendNow('620-610:UTC'),
90+
'Wrapping same day: between end and start should return false',
91+
)
92+
})
93+
94+
test('isWeekendNow - ET', (t) => {
95+
// Saturday 12:00 UTC = Saturday 07:00 EST -> 607
96+
clock.setSystemTime(new Date('2024-01-06T12:00:00Z').getTime())
97+
98+
t.false(isWeekendNow('000-123:America/New_York'), 'Before start day')
99+
t.false(isWeekendNow('400-500:America/New_York'), 'After end day')
100+
t.false(isWeekendNow('608-620:America/New_York'), 'Before start hour')
101+
t.false(isWeekendNow('605-607:America/New_York'), 'After end hour')
102+
103+
t.true(isWeekendNow('605-620:America/New_York'), 'Non-wrapping: middle of weekend')
104+
t.true(isWeekendNow('600-023:America/New_York'), 'Non-wrapping: spanning multiple days')
105+
t.true(isWeekendNow('607-610:America/New_York'), 'Non-wrapping: same day, at start hour ')
106+
107+
t.true(isWeekendNow('520-020:America/New_York'), 'Wrapping: nowDay > startDay should return true')
108+
t.true(isWeekendNow('400-200:America/New_York'), 'Wrapping: nowDay > startDay should return true')
109+
110+
t.true(isWeekendNow('607-020:America/New_York'), 'After start hour')
111+
t.true(isWeekendNow('520-608:America/New_York'), 'Before end hour')
112+
113+
t.false(isWeekendNow('620-607:America/New_York'), 'Wrapping same day: at end hour should')
114+
})
115+
116+
test('isWeekendNow - ET - Fri to Sun 8 to 8', (t) => {
117+
// Weekend: Fri 20:00 to Sun 20:00 ET (520-020:America/New_York)
118+
const range = '520-020:America/New_York'
119+
// Thu 21:00 ET
120+
clock.setSystemTime(new Date('2024-01-05T02:00:00Z').getTime())
121+
t.false(isWeekendNow(range), 'Before start day')
122+
// Fri 19:00 ET
123+
clock.setSystemTime(new Date('2024-01-06T00:00:00Z').getTime())
124+
t.false(isWeekendNow(range), 'On start day, before start hour')
125+
// Fri 20:00 ET
126+
clock.setSystemTime(new Date('2024-01-06T01:00:00Z').getTime())
127+
t.true(isWeekendNow(range), 'On start day, at start hour')
128+
// Fri 23:00 ET
129+
clock.setSystemTime(new Date('2024-01-06T04:00:00Z').getTime())
130+
t.true(isWeekendNow(range), 'On start day, after start hour')
131+
// Sat 12:00 ET
132+
clock.setSystemTime(new Date('2024-01-06T17:00:00Z').getTime())
133+
t.true(isWeekendNow(range), 'Middle day (Saturday)')
134+
// Sun 19:00 ET
135+
clock.setSystemTime(new Date('2024-01-08T00:00:00Z').getTime())
136+
t.true(isWeekendNow(range), 'On end day, before end hour')
137+
// Sun 20:00 ET
138+
clock.setSystemTime(new Date('2024-01-08T01:00:00Z').getTime())
139+
t.false(isWeekendNow(range), 'On end day, at end hour')
140+
// Sun 21:00 ET
141+
clock.setSystemTime(new Date('2024-01-08T02:00:00Z').getTime())
142+
t.false(isWeekendNow(range), 'On end day, after end hour')
143+
// Mon 10:00 ET
144+
clock.setSystemTime(new Date('2024-01-08T15:00:00Z').getTime())
145+
t.false(isWeekendNow(range), 'After end day')
146+
})

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
dependencies:
1515
"@jridgewell/trace-mapping" "0.3.9"
1616

17+
"@date-fns/tz@1.4.1":
18+
version "1.4.1"
19+
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4"
20+
integrity sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==
21+
1722
"@eslint-community/eslint-utils@^4.7.0":
1823
version "4.7.0"
1924
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"

0 commit comments

Comments
 (0)