@waterx/sdk 4.3.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/waterx-sdk-integration/SKILL.md +225 -0
- package/README.md +216 -45
- package/SKILLS.md +34 -0
- package/dist/cjs/src/account/config.d.ts +3 -5
- package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
- package/dist/cjs/src/account/funding/wormhole.js +1 -2
- package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
- package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +163 -8
- package/dist/cjs/src/oracle/aggregate.d.ts +49 -62
- package/dist/cjs/src/oracle/aggregate.js +208 -164
- package/dist/cjs/src/oracle/config.d.ts +38 -51
- package/dist/cjs/src/oracle/config.js +1 -1
- package/dist/cjs/src/oracle/host.d.ts +14 -19
- package/dist/cjs/src/oracle/host.js +3 -3
- package/dist/cjs/src/oracle/index.d.ts +25 -16
- package/dist/cjs/src/oracle/index.js +87 -69
- package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
- package/dist/cjs/src/oracle/price-update-rule.js +24 -16
- package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
- package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
- package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
- package/dist/cjs/src/oracle/read-plane.js +26 -48
- package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
- package/dist/cjs/src/oracle/read-prices.js +162 -0
- package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
- package/dist/cjs/src/oracle/rule-registry.js +12 -14
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
- package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +271 -57
- package/dist/cjs/src/oracle/rules/waterx-rule.js +743 -144
- package/dist/cjs/src/oracle/schedule.d.ts +109 -0
- package/dist/cjs/src/oracle/schedule.js +991 -0
- package/dist/cjs/src/oracle/source-list.d.ts +31 -27
- package/dist/cjs/src/oracle/source-list.js +43 -45
- package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
- package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
- package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
- package/dist/cjs/src/oracle/update-fetch.js +12 -19
- package/dist/cjs/src/oracle/validate.d.ts +105 -0
- package/dist/cjs/src/oracle/validate.js +185 -0
- package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
- package/dist/cjs/src/oracle/weight-coverage.js +173 -0
- package/dist/cjs/src/perp/client.d.ts +40 -55
- package/dist/cjs/src/perp/client.js +44 -17
- package/dist/cjs/src/perp/config-view.d.ts +5 -14
- package/dist/cjs/src/perp/config-view.js +5 -16
- package/dist/cjs/src/perp/config.d.ts +3 -3
- package/dist/cjs/src/perp/config.js +11 -13
- package/dist/cjs/src/perp/index.d.ts +4 -6
- package/dist/cjs/src/perp/index.js +43 -18
- package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
- package/dist/cjs/src/perp/tx-builders/common.js +115 -47
- package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
- package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
- package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
- package/dist/cjs/src/perp/tx-builders.js +16 -10
- package/dist/cjs/src/unified-client.d.ts +9 -35
- package/dist/cjs/src/unified-client.js +3 -4
- package/dist/cjs/src/utils/config.d.ts +0 -8
- package/dist/cjs/src/utils/config.js +5 -13
- package/dist/src/account/config.d.ts +3 -5
- package/dist/src/account/funding/wormhole.d.ts +1 -2
- package/dist/src/account/funding/wormhole.js +1 -2
- package/dist/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
- package/dist/src/generated/waterx_rule/waterx_rule.js +151 -7
- package/dist/src/oracle/aggregate.d.ts +49 -62
- package/dist/src/oracle/aggregate.js +209 -164
- package/dist/src/oracle/config.d.ts +38 -51
- package/dist/src/oracle/config.js +1 -1
- package/dist/src/oracle/host.d.ts +14 -19
- package/dist/src/oracle/host.js +3 -3
- package/dist/src/oracle/index.d.ts +25 -16
- package/dist/src/oracle/index.js +61 -54
- package/dist/src/oracle/price-update-rule.d.ts +104 -66
- package/dist/src/oracle/price-update-rule.js +23 -16
- package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
- package/dist/src/oracle/pyth-pro-history.js +38 -0
- package/dist/src/oracle/read-plane.d.ts +35 -53
- package/dist/src/oracle/read-plane.js +25 -47
- package/dist/src/oracle/read-prices.d.ts +105 -0
- package/dist/src/oracle/read-prices.js +156 -0
- package/dist/src/oracle/rule-registry.d.ts +12 -12
- package/dist/src/oracle/rule-registry.js +12 -14
- package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
- package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
- package/dist/src/oracle/rules/waterx-rule.d.ts +271 -57
- package/dist/src/oracle/rules/waterx-rule.js +735 -144
- package/dist/src/oracle/schedule.d.ts +109 -0
- package/dist/src/oracle/schedule.js +985 -0
- package/dist/src/oracle/source-list.d.ts +31 -27
- package/dist/src/oracle/source-list.js +42 -43
- package/dist/src/oracle/symbol-catalog.d.ts +54 -0
- package/dist/src/oracle/symbol-catalog.js +65 -0
- package/dist/src/oracle/update-fetch.d.ts +11 -15
- package/dist/src/oracle/update-fetch.js +12 -18
- package/dist/src/oracle/validate.d.ts +105 -0
- package/dist/src/oracle/validate.js +177 -0
- package/dist/src/oracle/weight-coverage.d.ts +79 -0
- package/dist/src/oracle/weight-coverage.js +166 -0
- package/dist/src/perp/client.d.ts +40 -55
- package/dist/src/perp/client.js +45 -18
- package/dist/src/perp/config-view.d.ts +5 -14
- package/dist/src/perp/config-view.js +5 -16
- package/dist/src/perp/config.d.ts +3 -3
- package/dist/src/perp/config.js +11 -13
- package/dist/src/perp/index.d.ts +4 -6
- package/dist/src/perp/index.js +17 -5
- package/dist/src/perp/tx-builders/common.d.ts +86 -48
- package/dist/src/perp/tx-builders/common.js +114 -48
- package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
- package/dist/src/perp/tx-builders/wlp.js +6 -35
- package/dist/src/perp/tx-builders.d.ts +6 -5
- package/dist/src/perp/tx-builders.js +15 -6
- package/dist/src/unified-client.d.ts +9 -35
- package/dist/src/unified-client.js +3 -4
- package/dist/src/utils/config.d.ts +0 -8
- package/dist/src/utils/config.js +5 -12
- package/package.json +8 -2
- package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
- package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
- package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
- package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
- package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
- package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
- package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
- package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
- package/dist/cjs/src/oracle/pyth.d.ts +0 -227
- package/dist/cjs/src/oracle/pyth.js +0 -652
- package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
- package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
- package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
- package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
- package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
- package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
- package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
- package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
- package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
- package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
- package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
- package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
- package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
- package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
- package/dist/src/oracle/pyth.d.ts +0 -227
- package/dist/src/oracle/pyth.js +0 -638
- package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
- package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
- package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
- package/dist/src/oracle/rules/pyth-rule.js +0 -26
- package/dist/src/oracle/rules/sponsor.d.ts +0 -32
- package/dist/src/oracle/rules/sponsor.js +0 -52
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `schedule.ts` — Pyth `attributes.schedule` grammar parser + the pure
|
|
3
|
+
* market-status walker (WL-2345): the ONE implementation both consumers fold
|
|
4
|
+
* onto (the FE fallback `/api/markets` route and the BE
|
|
5
|
+
* `PythScheduleService` / markets service carried drifting twins).
|
|
6
|
+
*
|
|
7
|
+
* Parser grammar (https://docs.pyth.network/price-feeds/market-hours):
|
|
8
|
+
*
|
|
9
|
+
* {IANA_timezone};{Mon},{Tue},{Wed},{Thu},{Fri},{Sat},{Sun};{holidays_csv}
|
|
10
|
+
*
|
|
11
|
+
* Each weekday slot is `Open`/`O`/`open`, `Closed`/`C`/`closed`, one
|
|
12
|
+
* `HHMM-HHMM` range, `&`-joined ranges (the new multi-session encoding), or
|
|
13
|
+
* comma-joined ranges (the old encoding — disambiguated from the weekday
|
|
14
|
+
* separator by lookahead). Holidays are `MMDD` (old) or `MMDD/C` (new; a
|
|
15
|
+
* `MMDD/HHMM-HHMM` modified-hours entry is NOT a closure and is skipped, as
|
|
16
|
+
* are non-MMDD sentinels like Pyth's `"0"`). This is the reconciled SUPERSET
|
|
17
|
+
* of the two prior ports — accepting either era's tokens is what lets one
|
|
18
|
+
* parser serve the `v1/symbols` catalog and any cached Hermes-era strings.
|
|
19
|
+
*
|
|
20
|
+
* Pyth weekday order is Mon=0..Sun=6; `TradingHours.days` uses ISO-ish
|
|
21
|
+
* Sun=0..Sat=6 (converted in `groupIntoSessions`).
|
|
22
|
+
*
|
|
23
|
+
* The schedule catalog itself comes from `fetchPythSymbolCatalog`
|
|
24
|
+
* (`symbol-catalog.ts`); pairing a record's `schedule` with this parser and
|
|
25
|
+
* `getMarketStatus` is the whole market-hours pipeline.
|
|
26
|
+
*/
|
|
27
|
+
import { MS_PER_MINUTE } from "../constants.js";
|
|
28
|
+
export class PythScheduleParseError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "PythScheduleParseError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Parser
|
|
36
|
+
// ============================================================================
|
|
37
|
+
/**
|
|
38
|
+
* Parse a Pyth schedule string. Throws {@link PythScheduleParseError} on
|
|
39
|
+
* malformed input — callers guard with try/catch so one bad feed does not break
|
|
40
|
+
* a whole market list.
|
|
41
|
+
*
|
|
42
|
+
* DEGRADE CLOSED, not open. The obvious fallback — treat an unparseable
|
|
43
|
+
* schedule as "no schedule (24/7)" — is the wrong direction: the shapes that
|
|
44
|
+
* actually fail here are venues WITH sessions (a lunch-break equity schedule in
|
|
45
|
+
* the legacy comma encoding, an ambiguous weekday fold), and calling those 24/7
|
|
46
|
+
* reports a closed venue as tradable. That is the failure class this module
|
|
47
|
+
* exists to prevent. Prefer marking the market unavailable, or reusing the last
|
|
48
|
+
* schedule that parsed; use 24/7 only where a wrong "open" is harmless.
|
|
49
|
+
*/
|
|
50
|
+
export function parsePythSchedule(input) {
|
|
51
|
+
const segments = input.split(";");
|
|
52
|
+
if (segments.length < 2 || segments.length > 3) {
|
|
53
|
+
throw new PythScheduleParseError(`expected 2 or 3 ';'-separated segments, got ${String(segments.length)}: ${input}`);
|
|
54
|
+
}
|
|
55
|
+
const timezone = (segments[0] ?? "").trim();
|
|
56
|
+
const weeklyStr = (segments[1] ?? "").trim();
|
|
57
|
+
const holidaysStr = (segments[2] ?? "").trim();
|
|
58
|
+
assertUsableTimezone(timezone);
|
|
59
|
+
const weeklySlots = parseWeeklySlots(weeklyStr);
|
|
60
|
+
const holidays = parseHolidays(holidaysStr);
|
|
61
|
+
const sessions = groupIntoSessions(weeklySlots);
|
|
62
|
+
const allOpen = weeklySlots.every((day) => {
|
|
63
|
+
if (day.length !== 1)
|
|
64
|
+
return false;
|
|
65
|
+
const first = day.at(0);
|
|
66
|
+
return first?.open === 0 && first.close === 1440;
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
tradingHours: {
|
|
70
|
+
timezone,
|
|
71
|
+
sessions,
|
|
72
|
+
...(holidays.length !== 0 ? { holidays } : {}),
|
|
73
|
+
},
|
|
74
|
+
alwaysOpen: allOpen && holidays.length === 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Reject a timezone the runtime cannot actually resolve, AT PARSE TIME.
|
|
79
|
+
*
|
|
80
|
+
* A shape check alone let `"Not/AReal_Zone"` through the parser and then blew
|
|
81
|
+
* up much later inside `getMarketStatus` as an untyped `RangeError` — past the
|
|
82
|
+
* try/catch this module's docs tell callers to put around `parsePythSchedule`,
|
|
83
|
+
* so one malformed record in the ~3.6k-entry catalog took down a whole markets
|
|
84
|
+
* response instead of degrading that one feed. Constructing the
|
|
85
|
+
* formatter here is also what keeps `fmtCache` bounded to REAL zones: a bogus
|
|
86
|
+
* name throws before it can be cached.
|
|
87
|
+
*/
|
|
88
|
+
function assertUsableTimezone(tz) {
|
|
89
|
+
// Cheap shape gate first — it rejects the obvious junk without paying for an
|
|
90
|
+
// `Intl` construction, and keeps the error identical for both failure modes.
|
|
91
|
+
if (!/^[A-Za-z_]+(\/[A-Za-z_+\-0-9]+){0,2}$/.test(tz)) {
|
|
92
|
+
throw new PythScheduleParseError(`invalid timezone: ${tz}`);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
getFmt(tz);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new PythScheduleParseError(`invalid timezone: ${tz}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** `Open`/`O`/`open` → a full day; `Closed`/`C`/`closed` → an empty day; else `null`. */
|
|
102
|
+
function keywordDay(token) {
|
|
103
|
+
if (token === "Open" || token === "O" || token === "open")
|
|
104
|
+
return [{ open: 0, close: 1440 }];
|
|
105
|
+
if (token === "Closed" || token === "C" || token === "closed")
|
|
106
|
+
return [];
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Split the weekly segment into exactly 7 day slots.
|
|
111
|
+
*
|
|
112
|
+
* Pyth uses `,` both BETWEEN weekdays and (in the legacy encoding) between two
|
|
113
|
+
* sessions of the SAME weekday, so a token list longer than 7 has to be folded
|
|
114
|
+
* — and which day owns the extra token is not recoverable from the flat list
|
|
115
|
+
* alone. (`&` exists precisely to remove that ambiguity and is handled as one
|
|
116
|
+
* self-contained day token.)
|
|
117
|
+
*
|
|
118
|
+
* There is no direction that is right in general. Leftmost-greedy (what both
|
|
119
|
+
* consumer copies shipped) mis-assigns `R,R,R,R,R,C,R,R`; rightmost-greedy
|
|
120
|
+
* mis-assigns the legacy FX form `0000-1700,1701-2400,O,O,O,O,0000-1700,
|
|
121
|
+
* 1701-2400`, where the surplus belongs to MONDAY but the last adjacent run
|
|
122
|
+
* is Sunday's. Either way the result is a plausible-looking weekly calendar
|
|
123
|
+
* that is simply wrong, with `slots.length === 7` so nothing throws — and a
|
|
124
|
+
* wrong calendar reports a closed venue as tradable.
|
|
125
|
+
*
|
|
126
|
+
* So this does not guess. A fold is applied only when the token list admits
|
|
127
|
+
* exactly ONE reading: exactly one maximal run of adjacent plain ranges can
|
|
128
|
+
* absorb the surplus by collapsing entirely into one day
|
|
129
|
+
* (`merges === run.length - 1`).
|
|
130
|
+
* Anything else raises {@link PythScheduleParseError} naming the ambiguity,
|
|
131
|
+
* which a caller can degrade on. The lunch-break shape
|
|
132
|
+
* `0930-1200,1330-1600,C,C,C,C,C,C` is the unambiguous case and still parses.
|
|
133
|
+
* Note a genuinely ambiguous legacy string (a Tokyo-style lunch break repeated
|
|
134
|
+
* across five weekdays: one 10-token run against 5 merges) throws — see this
|
|
135
|
+
* function's caller docs for why the fallback must not be "24/7".
|
|
136
|
+
*
|
|
137
|
+
* Live Pyth is unaffected either way: every one of the 3619 schedules in the
|
|
138
|
+
* production catalog encodes multi-session days with `&` and carries exactly
|
|
139
|
+
* 7 weekly tokens, so this path is reached only by legacy or third-party
|
|
140
|
+
* payloads (verified against `/v1/symbols`, 2026-08-21).
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* A token that can take part in a fold: a plain range, i.e. neither a keyword
|
|
144
|
+
* (pins its own day) nor an explicit `&` day (already one complete slot).
|
|
145
|
+
*/
|
|
146
|
+
function isFoldable(token) {
|
|
147
|
+
return keywordDay(token) === null && !token.includes("&");
|
|
148
|
+
}
|
|
149
|
+
/** Maximal runs of adjacent foldable tokens — the only places surplus can go. */
|
|
150
|
+
function foldableRuns(tokens) {
|
|
151
|
+
const runs = [];
|
|
152
|
+
for (let i = 0; i < tokens.length;) {
|
|
153
|
+
if (!isFoldable(tokens[i])) {
|
|
154
|
+
i += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const start = i;
|
|
158
|
+
while (i < tokens.length && isFoldable(tokens[i]))
|
|
159
|
+
i += 1;
|
|
160
|
+
if (i - start >= 2)
|
|
161
|
+
runs.push({ start, length: i - start });
|
|
162
|
+
}
|
|
163
|
+
return runs;
|
|
164
|
+
}
|
|
165
|
+
function parseWeeklySlots(input) {
|
|
166
|
+
const tokens = input
|
|
167
|
+
.split(",")
|
|
168
|
+
.map((t) => t.trim())
|
|
169
|
+
.filter((t) => t.length > 0);
|
|
170
|
+
// How many surplus range tokens must be folded into a neighbouring day. A
|
|
171
|
+
// shortfall is unparseable up front; a surplus the fold cannot actually
|
|
172
|
+
// absorb (keyword tokens pin their own day and never merge) simply leaves
|
|
173
|
+
// too many slots, which the count check after the walk rejects.
|
|
174
|
+
const merges = tokens.length - 7;
|
|
175
|
+
if (merges < 0) {
|
|
176
|
+
throw new PythScheduleParseError(`expected 7 weekday slots, got ${String(tokens.length)}: ${input}`);
|
|
177
|
+
}
|
|
178
|
+
// Scanning for runs is only meaningful when there IS a surplus to place, and
|
|
179
|
+
// every schedule in the live catalog has exactly 7 tokens — so on real input
|
|
180
|
+
// this whole block is skipped.
|
|
181
|
+
let foldAt = null;
|
|
182
|
+
if (merges > 0) {
|
|
183
|
+
// A run can only take the surplus unambiguously by collapsing ENTIRELY
|
|
184
|
+
// into one day. Absorbing fewer tokens than it holds would leave a choice
|
|
185
|
+
// of which tokens pair up (`R,R,R` with one merge is two readings), so a
|
|
186
|
+
// longer run is not a candidate at all rather than a preferred one.
|
|
187
|
+
const usable = foldableRuns(tokens).filter((run) => run.length - 1 === merges);
|
|
188
|
+
if (usable.length !== 1) {
|
|
189
|
+
throw new PythScheduleParseError(`ambiguous weekday fold: ${String(tokens.length)} tokens for 7 days, ` +
|
|
190
|
+
`${String(usable.length)} ways to assign the surplus — cannot tell which day ` +
|
|
191
|
+
`owns it: ${input}`);
|
|
192
|
+
}
|
|
193
|
+
foldAt = usable[0];
|
|
194
|
+
}
|
|
195
|
+
const slots = [];
|
|
196
|
+
for (let i = 0; i < tokens.length;) {
|
|
197
|
+
const token = tokens[i];
|
|
198
|
+
if (foldAt !== null && i === foldAt.start) {
|
|
199
|
+
// The one unambiguous run: the whole run collapses into a single day.
|
|
200
|
+
slots.push(tokens.slice(i, i + foldAt.length).map((t) => parseRange(t)));
|
|
201
|
+
i += foldAt.length;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const keyword = keywordDay(token);
|
|
205
|
+
if (keyword !== null) {
|
|
206
|
+
slots.push(keyword);
|
|
207
|
+
}
|
|
208
|
+
else if (token.includes("&")) {
|
|
209
|
+
// Explicit multi-session day — already one complete slot, never folded.
|
|
210
|
+
slots.push(token.split("&").map((part) => parseRange(part.trim())));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
slots.push([parseRange(token)]);
|
|
214
|
+
}
|
|
215
|
+
i += 1;
|
|
216
|
+
}
|
|
217
|
+
// No count check here: the walk emits one slot per token except at the one
|
|
218
|
+
// fold, which collapses exactly `merges + 1` of them, so `slots.length` is
|
|
219
|
+
// `tokens.length - merges` — 7 by the arithmetic above. The unparseable
|
|
220
|
+
// cases (too few tokens, an ambiguous surplus) already threw.
|
|
221
|
+
return slots;
|
|
222
|
+
}
|
|
223
|
+
function parseRange(input) {
|
|
224
|
+
const parts = input.split("-");
|
|
225
|
+
const p0 = parts[0];
|
|
226
|
+
const p1 = parts[1];
|
|
227
|
+
if (parts.length !== 2 || p0 === undefined || p1 === undefined) {
|
|
228
|
+
throw new PythScheduleParseError(`invalid HHMM-HHMM range: ${input}`);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
open: parseHHMMCompact(p0),
|
|
232
|
+
close: parseHHMMCompact(p1),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function parseHHMMCompact(input) {
|
|
236
|
+
if (!/^\d{4}$/.test(input)) {
|
|
237
|
+
throw new PythScheduleParseError(`invalid HHMM: ${input}`);
|
|
238
|
+
}
|
|
239
|
+
const hours = Number(input.slice(0, 2));
|
|
240
|
+
const minutes = Number(input.slice(2));
|
|
241
|
+
if (hours > 24 || minutes > 59 || (hours === 24 && minutes !== 0)) {
|
|
242
|
+
throw new PythScheduleParseError(`invalid HHMM: ${input}`);
|
|
243
|
+
}
|
|
244
|
+
return hours * 60 + minutes;
|
|
245
|
+
}
|
|
246
|
+
function parseHolidays(input) {
|
|
247
|
+
if (input === "")
|
|
248
|
+
return [];
|
|
249
|
+
const results = [];
|
|
250
|
+
for (const raw of input
|
|
251
|
+
.split(",")
|
|
252
|
+
.map((s) => s.trim())
|
|
253
|
+
.filter((s) => s.length > 0)) {
|
|
254
|
+
// New format: MMDD/action — e.g. 0101/C (closed) or 1224/0000-1700 (modified hours).
|
|
255
|
+
// Old format: bare MMDD.
|
|
256
|
+
const slashIdx = raw.indexOf("/");
|
|
257
|
+
const mmdd = slashIdx !== -1 ? raw.slice(0, slashIdx) : raw;
|
|
258
|
+
const action = slashIdx !== -1 ? raw.slice(slashIdx + 1) : undefined;
|
|
259
|
+
// Non-MMDD sentinels (e.g. Pyth's '0' placeholder for "no holidays") — skip.
|
|
260
|
+
if (!/^\d{4}$/.test(mmdd))
|
|
261
|
+
continue;
|
|
262
|
+
const month = Number(mmdd.slice(0, 2));
|
|
263
|
+
const day = Number(mmdd.slice(2));
|
|
264
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
|
265
|
+
throw new PythScheduleParseError(`invalid holiday MMDD: ${mmdd}`);
|
|
266
|
+
}
|
|
267
|
+
// Closure spelling matches `keywordDay`'s tolerance (`C` / `Closed`, any
|
|
268
|
+
// case): live Pyth only ever emits `C`, but accepting one spelling here
|
|
269
|
+
// and three there is the kind of asymmetry that silently drops a holiday.
|
|
270
|
+
// Anything else is MODIFIED HOURS — `0930-1300`, or `&`-joined windows —
|
|
271
|
+
// which replace the weekly schedule for that date rather than being a
|
|
272
|
+
// closure. Discarding them made an early-close day read as a normal one.
|
|
273
|
+
let sessions;
|
|
274
|
+
if (action !== undefined && !/^(c|closed)$/i.test(action)) {
|
|
275
|
+
sessions = action.split("&").map((part) => {
|
|
276
|
+
const window = parseRange(part.trim());
|
|
277
|
+
return { open: minutesToHHMM(window.open), close: minutesToHHMM(window.close) };
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
results.push(sessions ? { month, day, sessions } : { month, day });
|
|
281
|
+
}
|
|
282
|
+
return results;
|
|
283
|
+
}
|
|
284
|
+
function groupIntoSessions(slots) {
|
|
285
|
+
const groups = new Map();
|
|
286
|
+
for (let pythDay = 0; pythDay < 7; pythDay++) {
|
|
287
|
+
// Pyth Mon=0 → Sun-first Mon=1; Pyth Sun=6 → Sun=0.
|
|
288
|
+
const isoDay = (pythDay + 1) % 7;
|
|
289
|
+
// slots has exactly 7 elements (validated by parseWeeklySlots)
|
|
290
|
+
const daySlots = slots[pythDay];
|
|
291
|
+
if (daySlots === undefined)
|
|
292
|
+
continue;
|
|
293
|
+
for (const window of daySlots) {
|
|
294
|
+
const key = `${String(window.open)}-${String(window.close)}`;
|
|
295
|
+
const existing = groups.get(key);
|
|
296
|
+
if (existing !== undefined) {
|
|
297
|
+
existing.days.push(isoDay);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
groups.set(key, {
|
|
301
|
+
open: window.open,
|
|
302
|
+
close: window.close,
|
|
303
|
+
days: [isoDay],
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return [...groups.values()]
|
|
309
|
+
.map(({ open, close, days }) => ({
|
|
310
|
+
open: minutesToHHMM(open),
|
|
311
|
+
close: minutesToHHMM(close === 1440 ? 0 : close),
|
|
312
|
+
days: [...days].sort((a, b) => a - b),
|
|
313
|
+
}))
|
|
314
|
+
.sort((a, b) => a.open.localeCompare(b.open));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Minutes-from-midnight → `"HH:MM"`. A 1440 close renders `"24:00"`, which is
|
|
318
|
+
* what a day-scoped holiday window wants: end-of-day has to stay
|
|
319
|
+
* distinguishable from start-of-day. Weekly sessions map it to `"00:00"` at
|
|
320
|
+
* the call site, because there a close <= open already means "next day".
|
|
321
|
+
*/
|
|
322
|
+
function minutesToHHMM(minutes) {
|
|
323
|
+
const h = Math.floor(minutes / 60);
|
|
324
|
+
const m = minutes % 60;
|
|
325
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
326
|
+
}
|
|
327
|
+
// ============================================================================
|
|
328
|
+
// Market-status walker (pure; holiday-aware weekly schedule)
|
|
329
|
+
// ============================================================================
|
|
330
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
331
|
+
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY;
|
|
332
|
+
/** How far the holiday walks look ahead — covers any plausible cluster of consecutive closures. */
|
|
333
|
+
/**
|
|
334
|
+
* How far to scan for the next holiday BOUNDARY.
|
|
335
|
+
*
|
|
336
|
+
* Distinct from {@link LOOKAHEAD_WEEKS}, which bounds how far the OPEN-event
|
|
337
|
+
* walk looks. The next holiday itself can be most of a year out —
|
|
338
|
+
* a 24/7 venue queried on Dec 1 with only a `1225` holiday used to fall off
|
|
339
|
+
* the 21-day window and report `nextStatusChangeIn: null`, which the result
|
|
340
|
+
* type documents as "24/7 or paused". A consumer caching on that holds "open
|
|
341
|
+
* forever" straight through the closure. Holidays are `MMDD`, so a year is a
|
|
342
|
+
* complete answer, and the scan is integer date arithmetic that converts only
|
|
343
|
+
* the winning day.
|
|
344
|
+
*/
|
|
345
|
+
const HOLIDAY_LOOKAHEAD_DAYS = 366;
|
|
346
|
+
const LOOKAHEAD_WEEKS = 3;
|
|
347
|
+
/**
|
|
348
|
+
* Pure function — calculates market open/closed status from trading hours + now.
|
|
349
|
+
*
|
|
350
|
+
* @param tradingHours null means 24/7 (crypto)
|
|
351
|
+
* @param paused forces `paused`, short-circuiting the schedule. Checked
|
|
352
|
+
* BEFORE the 24/7 branch, so an on-chain-paused crypto
|
|
353
|
+
* market (tradingHours === null) still reports paused.
|
|
354
|
+
* @param now injectable for testing; defaults to new Date()
|
|
355
|
+
*/
|
|
356
|
+
export function getMarketStatus(tradingHours, paused, now) {
|
|
357
|
+
if (paused) {
|
|
358
|
+
return { status: "paused", nextStatusChangeIn: null };
|
|
359
|
+
}
|
|
360
|
+
if (tradingHours === null) {
|
|
361
|
+
return { status: "open", nextStatusChangeIn: null };
|
|
362
|
+
}
|
|
363
|
+
const current = now ?? new Date();
|
|
364
|
+
const result = computeScheduledStatus(tradingHours, current);
|
|
365
|
+
// Every delta above is computed from minute-of-week arithmetic, i.e. as if
|
|
366
|
+
// `now` sat exactly on the start of the current minute — so a boundary was
|
|
367
|
+
// reported up to 59.999s LATE, and a consumer scheduling a re-check off this
|
|
368
|
+
// value woke to find the status had already changed. Subtract the sub-minute
|
|
369
|
+
// offset. (`getTime() % 60_000` is zone-independent: every modern IANA offset
|
|
370
|
+
// is a whole number of minutes.)
|
|
371
|
+
if (result.nextStatusChangeIn === null)
|
|
372
|
+
return result;
|
|
373
|
+
const intoMinute = current.getTime() % MS_PER_MINUTE;
|
|
374
|
+
return {
|
|
375
|
+
...result,
|
|
376
|
+
nextStatusChangeIn: Math.max(0, result.nextStatusChangeIn - intoMinute),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// `Intl.DateTimeFormat` construction is heavy; cache one formatter per
|
|
380
|
+
// timezone so per-request hot paths (a markets list endpoint) don't
|
|
381
|
+
// re-allocate it.
|
|
382
|
+
const fmtCache = new Map();
|
|
383
|
+
/**
|
|
384
|
+
* Per-`TradingHours` derived state — the sorted/merged weekly event list and
|
|
385
|
+
* the holiday lookup set. Both are pure functions of the schedule object, and
|
|
386
|
+
* `getMarketStatus` is called per market per request against the SAME objects
|
|
387
|
+
* (a service refreshes its schedule map on an interval, not per call), so
|
|
388
|
+
* re-parsing every `"HH:MM"` and re-sorting on each call is pure rework —
|
|
389
|
+
* exactly the cost `fmtCache` above already avoids for the formatter.
|
|
390
|
+
*
|
|
391
|
+
* A `WeakMap` keeps this leak-free: an entry dies with the schedule object it
|
|
392
|
+
* describes, so a refreshed map's old entries are collectable.
|
|
393
|
+
*
|
|
394
|
+
* CONTRACT: a `TradingHours` is treated as IMMUTABLE. The cache is keyed on
|
|
395
|
+
* object identity, so mutating one in place (`hours.holidays = next`) keeps
|
|
396
|
+
* returning the state derived before the mutation, for the object's lifetime
|
|
397
|
+
* and with no way to invalidate. Refresh by replacing the object — which is
|
|
398
|
+
* what a service rebuilding its schedule map from a catalog fetch does anyway.
|
|
399
|
+
*/
|
|
400
|
+
const derivedCache = new WeakMap();
|
|
401
|
+
function derive(tradingHours) {
|
|
402
|
+
let derived = derivedCache.get(tradingHours);
|
|
403
|
+
if (!derived) {
|
|
404
|
+
// The walker is exported for a shape that crosses process boundaries
|
|
405
|
+
// (cached JSON, the consumers' own markets types), so it routinely arrives
|
|
406
|
+
// WITHOUT having passed through `parsePythSchedule` — where this check
|
|
407
|
+
// otherwise lives. Unchecked, a bad zone surfaces as a raw `RangeError`
|
|
408
|
+
// from `Intl` deep inside `toLocalParts`, escaping the try/catch callers
|
|
409
|
+
// put around the parser and failing a whole markets response over one row.
|
|
410
|
+
// Behind the cache miss, so it costs one regex per schedule object.
|
|
411
|
+
assertUsableTimezone(tradingHours.timezone);
|
|
412
|
+
derived = {
|
|
413
|
+
...buildWeeklyEvents(tradingHours.sessions),
|
|
414
|
+
holidays: buildHolidaySet(tradingHours.holidays),
|
|
415
|
+
holidayHours: buildHolidayHours(tradingHours.holidays),
|
|
416
|
+
};
|
|
417
|
+
derivedCache.set(tradingHours, derived);
|
|
418
|
+
}
|
|
419
|
+
return derived;
|
|
420
|
+
}
|
|
421
|
+
function getFmt(timezone) {
|
|
422
|
+
let fmt = fmtCache.get(timezone);
|
|
423
|
+
if (!fmt) {
|
|
424
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
425
|
+
timeZone: timezone,
|
|
426
|
+
weekday: "short",
|
|
427
|
+
hour: "2-digit",
|
|
428
|
+
minute: "2-digit",
|
|
429
|
+
month: "2-digit",
|
|
430
|
+
day: "2-digit",
|
|
431
|
+
year: "numeric",
|
|
432
|
+
hour12: false,
|
|
433
|
+
});
|
|
434
|
+
fmtCache.set(timezone, fmt);
|
|
435
|
+
}
|
|
436
|
+
return fmt;
|
|
437
|
+
}
|
|
438
|
+
function toLocalParts(date, timezone) {
|
|
439
|
+
const parts = getFmt(timezone).formatToParts(date);
|
|
440
|
+
const weekdayStr = parts.find((p) => p.type === "weekday")?.value ?? "Sun";
|
|
441
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
|
442
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
|
443
|
+
const month = Number(parts.find((p) => p.type === "month")?.value ?? "1");
|
|
444
|
+
const day = Number(parts.find((p) => p.type === "day")?.value ?? "1");
|
|
445
|
+
const year = Number(parts.find((p) => p.type === "year")?.value ?? "1970");
|
|
446
|
+
const dayMap = {
|
|
447
|
+
Sun: 0,
|
|
448
|
+
Mon: 1,
|
|
449
|
+
Tue: 2,
|
|
450
|
+
Wed: 3,
|
|
451
|
+
Thu: 4,
|
|
452
|
+
Fri: 5,
|
|
453
|
+
Sat: 6,
|
|
454
|
+
};
|
|
455
|
+
return { dayOfWeek: dayMap[weekdayStr] ?? 0, hour, minute, month, day, year };
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Parse `"HH:MM"` to `{ hour, minute }`.
|
|
459
|
+
*
|
|
460
|
+
* Validated, not coerced. `Number("9:30am".split(":")[1])` is `NaN`, and a NaN
|
|
461
|
+
* minute-of-week flows all the way to `new Date(NaN)`, where `Intl` throws a
|
|
462
|
+
* raw `RangeError: Invalid time value` — an untyped throw from deep inside the
|
|
463
|
+
* walker, which sails past the try/catch this module tells callers to put
|
|
464
|
+
* around the parser. Same escape hatch {@link assertUsableTimezone} closes for
|
|
465
|
+
* timezones, and it matters for the same reason: `TradingHours` reaches
|
|
466
|
+
* {@link getMarketStatus} from cached JSON and consumers' own types, so it has
|
|
467
|
+
* often never been through {@link parsePythSchedule}.
|
|
468
|
+
*/
|
|
469
|
+
function parseHHMM(s) {
|
|
470
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(s);
|
|
471
|
+
const hour = match ? Number(match[1]) : NaN;
|
|
472
|
+
const minute = match ? Number(match[2]) : NaN;
|
|
473
|
+
if (!match || hour > 24 || minute > 59 || (hour === 24 && minute !== 0)) {
|
|
474
|
+
throw new PythScheduleParseError(`invalid session time (expected HH:MM): ${s}`);
|
|
475
|
+
}
|
|
476
|
+
return { hour, minute };
|
|
477
|
+
}
|
|
478
|
+
/** Minutes from Sunday 00:00 for a given day + time. */
|
|
479
|
+
function minuteOfWeek(day, hour, minute) {
|
|
480
|
+
return day * 24 * 60 + hour * 60 + minute;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The instant at which the local clock next reads `targetMow` (minutes from
|
|
484
|
+
* Sunday 00:00 in `timezone`), plus that instant's local reading.
|
|
485
|
+
*
|
|
486
|
+
* A minute-of-week delta is LOCAL time; multiplying it by 60_000 assumes the
|
|
487
|
+
* UTC offset never moves, which is wrong across a DST boundary — a
|
|
488
|
+
* Friday-close → Monday-open countdown over a spring-forward came out a full
|
|
489
|
+
* hour late. So the naive delta is only a seed: we re-read the local clock at
|
|
490
|
+
* the guessed instant and fold the residual back in.
|
|
491
|
+
*
|
|
492
|
+
* `occurrence` selects a LATER repeat of the same weekly slot (0 = the next
|
|
493
|
+
* one, 1 = a week after that, …). It is applied to the seed, NOT added to the
|
|
494
|
+
* result, so every occurrence gets its own correction — adding
|
|
495
|
+
* `week × 604_800_000` afterwards would reintroduce the very fixed-offset
|
|
496
|
+
* assumption this function exists to remove.
|
|
497
|
+
*
|
|
498
|
+
* `from` lets a caller pass the local reading of `now` it already computed;
|
|
499
|
+
* `toLocalParts` is the heavy `Intl` path this module caches formatters for.
|
|
500
|
+
* The converged reading is returned for the same reason — callers that test
|
|
501
|
+
* the target date (the holiday walks) would otherwise recompute it.
|
|
502
|
+
*/
|
|
503
|
+
/**
|
|
504
|
+
* Minutes forward from `fromMow` to the next occurrence of `targetMow`.
|
|
505
|
+
*
|
|
506
|
+
* Never 0: an event whose minute-of-week is exactly `now` has already
|
|
507
|
+
* happened, so the NEXT one is a full week out. Both the resolver below and
|
|
508
|
+
* the candidate ordering in `findNextNonHolidayOpen` go through this — they
|
|
509
|
+
* used to compute it separately (`delta <= 0 ? +WEEK` vs a bare `% WEEK`) and
|
|
510
|
+
* disagreed on precisely that boundary, so the candidates were walked in an
|
|
511
|
+
* order the resolver did not share.
|
|
512
|
+
*/
|
|
513
|
+
function minutesUntilNextMow(fromMow, targetMow) {
|
|
514
|
+
const delta = targetMow - fromMow;
|
|
515
|
+
return delta <= 0 ? delta + MINUTES_PER_WEEK : delta;
|
|
516
|
+
}
|
|
517
|
+
function nextLocalMinuteOfWeek(now, timezone, targetMow, occurrence = 0, from) {
|
|
518
|
+
const local = from ?? toLocalParts(now, timezone);
|
|
519
|
+
const delta = minutesUntilNextMow(minuteOfWeek(local.dayOfWeek, local.hour, local.minute), targetMow);
|
|
520
|
+
let ms = (delta + occurrence * MINUTES_PER_WEEK) * MS_PER_MINUTE;
|
|
521
|
+
// One correction, then one verifying read — a single fold settles any
|
|
522
|
+
// standard ≤2h shift, and no IANA zone shifts twice inside that window.
|
|
523
|
+
let at = toLocalParts(new Date(now.getTime() + ms), timezone);
|
|
524
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
525
|
+
let residual = targetMow - minuteOfWeek(at.dayOfWeek, at.hour, at.minute);
|
|
526
|
+
// Fold into (−½ week, +½ week] so a week-boundary wrap isn't read as a
|
|
527
|
+
// week-long correction.
|
|
528
|
+
if (residual > MINUTES_PER_WEEK / 2)
|
|
529
|
+
residual -= MINUTES_PER_WEEK;
|
|
530
|
+
if (residual < -MINUTES_PER_WEEK / 2)
|
|
531
|
+
residual += MINUTES_PER_WEEK;
|
|
532
|
+
if (residual === 0)
|
|
533
|
+
break;
|
|
534
|
+
ms += residual * MS_PER_MINUTE;
|
|
535
|
+
at = toLocalParts(new Date(now.getTime() + ms), timezone);
|
|
536
|
+
}
|
|
537
|
+
return { ms, at };
|
|
538
|
+
}
|
|
539
|
+
/** Minutes elapsed since local midnight. */
|
|
540
|
+
function minutesIntoLocalDay(parts) {
|
|
541
|
+
return parts.hour * 60 + parts.minute;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Milliseconds until the local midnight `daysAhead` days from now (1 = the
|
|
545
|
+
* next one). Same seed-then-correct shape as
|
|
546
|
+
* {@link nextLocalMinuteOfWeek} — a day is not a fixed number of
|
|
547
|
+
* milliseconds across a DST change either.
|
|
548
|
+
*/
|
|
549
|
+
function msUntilLocalMidnight(now, timezone, daysAhead, from) {
|
|
550
|
+
let ms = (24 * 60 - minutesIntoLocalDay(from) + (daysAhead - 1) * 24 * 60) * MS_PER_MINUTE;
|
|
551
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
552
|
+
const intoDay = minutesIntoLocalDay(toLocalParts(new Date(now.getTime() + ms), timezone));
|
|
553
|
+
if (intoDay === 0)
|
|
554
|
+
break;
|
|
555
|
+
// Landed after midnight → pull back; landed before it (23:00 the previous
|
|
556
|
+
// day, a fall-back artefact) → push forward.
|
|
557
|
+
ms += (intoDay > 12 * 60 ? 24 * 60 - intoDay : -intoDay) * MS_PER_MINUTE;
|
|
558
|
+
}
|
|
559
|
+
return ms;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Milliseconds until the next local midnight that STARTS a day matching
|
|
563
|
+
* `wantHoliday` — the status-change clock for a 24/7 venue that observes
|
|
564
|
+
* holidays (its weekly event list is empty, so the event walker has nothing to
|
|
565
|
+
* measure). `null` when no such day falls inside the lookahead.
|
|
566
|
+
*
|
|
567
|
+
* The SEARCH is pure integer calendar arithmetic on `(month, day)` keys, and
|
|
568
|
+
* only the winning day is converted to an instant. Walking instants instead
|
|
569
|
+
* would spend an `Intl.formatToParts` per candidate day — and the dominant
|
|
570
|
+
* call is "when does the next holiday start", which on an ordinary day scans
|
|
571
|
+
* the whole window and finds nothing, so that cost is paid in full every time.
|
|
572
|
+
*/
|
|
573
|
+
function msUntilLocalDayStart(now, timezone, holidaySet, wantHoliday, from) {
|
|
574
|
+
// A UTC date is used purely as a calendar counter over the LOCAL date, so
|
|
575
|
+
// month lengths and leap years come out right without touching `Intl`.
|
|
576
|
+
const probe = new Date(Date.UTC(from.year, from.month - 1, from.day));
|
|
577
|
+
for (let daysAhead = 1; daysAhead <= HOLIDAY_LOOKAHEAD_DAYS; daysAhead++) {
|
|
578
|
+
probe.setUTCDate(probe.getUTCDate() + 1);
|
|
579
|
+
const candidate = { month: probe.getUTCMonth() + 1, day: probe.getUTCDate() };
|
|
580
|
+
if (isHoliday(holidaySet, candidate) === wantHoliday) {
|
|
581
|
+
return msUntilLocalMidnight(now, timezone, daysAhead, from);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Milliseconds until the next MODIFIED-hours session opens, or `null` if none
|
|
588
|
+
* falls inside the lookahead.
|
|
589
|
+
*
|
|
590
|
+
* Needed because a modified date is not tied to the weekly schedule: a venue
|
|
591
|
+
* that is normally shut on Sunday can still have a `1227/1000-1200` session,
|
|
592
|
+
* and the weekly event walk has no `open` event anywhere near it. Answering
|
|
593
|
+
* "when does trading next resume" purely from weekly events therefore skipped
|
|
594
|
+
* the session entirely and pointed at the following Monday — while querying
|
|
595
|
+
* during the session itself correctly reported open. That asymmetry is the bug.
|
|
596
|
+
*
|
|
597
|
+
* Same integer-calendar scan as {@link msUntilLocalDayStart}: only the winning
|
|
598
|
+
* day is converted to an instant.
|
|
599
|
+
*/
|
|
600
|
+
function msUntilNextModifiedOpen(now, timezone, holidayHours, from) {
|
|
601
|
+
if (holidayHours.size === 0)
|
|
602
|
+
return null;
|
|
603
|
+
const probe = new Date(Date.UTC(from.year, from.month - 1, from.day));
|
|
604
|
+
for (let daysAhead = 1; daysAhead <= HOLIDAY_LOOKAHEAD_DAYS; daysAhead++) {
|
|
605
|
+
probe.setUTCDate(probe.getUTCDate() + 1);
|
|
606
|
+
const windows = holidayHours.get(holidayKey(probe.getUTCMonth() + 1, probe.getUTCDate()));
|
|
607
|
+
if (windows === undefined || windows.length === 0)
|
|
608
|
+
continue;
|
|
609
|
+
const midnight = msUntilLocalMidnight(now, timezone, daysAhead, from);
|
|
610
|
+
return midnight + windows[0].start * MS_PER_MINUTE;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
/** The sooner of two candidate deltas, either of which may be absent. */
|
|
615
|
+
function soonest(a, b) {
|
|
616
|
+
if (a === null)
|
|
617
|
+
return b;
|
|
618
|
+
if (b === null)
|
|
619
|
+
return a;
|
|
620
|
+
return Math.min(a, b);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Build the week's open/close events — derived from MERGED OPEN INTERVALS, not
|
|
624
|
+
* by cancelling event pairs.
|
|
625
|
+
*
|
|
626
|
+
* The distinction matters. Cancelling a `close`+`open` at the same
|
|
627
|
+
* minute-of-week pairwise looks equivalent and is not: it assumes the sorted
|
|
628
|
+
* list alternates. Two sessions ending at the same minute (overlapping, or
|
|
629
|
+
* duplicated across a `&` day) put two `close`s in a row, the pairwise scan
|
|
630
|
+
* then eats the following `open`, and the market reads CLOSED for a session
|
|
631
|
+
* that is open — silently, since the resulting list is still well-formed.
|
|
632
|
+
*
|
|
633
|
+
* Coverage cannot express that. Each session-day becomes a half-open interval
|
|
634
|
+
* on the week circle, overlapping and touching intervals merge, and the
|
|
635
|
+
* boundaries of what survives ARE the events. Overlaps, duplicates and
|
|
636
|
+
* forex-continuous rollovers all collapse for the same reason instead of via
|
|
637
|
+
* three special cases, and the output alternates open/close by construction —
|
|
638
|
+
* which is exactly what `computeScheduledStatus` assumes.
|
|
639
|
+
*/
|
|
640
|
+
function buildWeeklyEvents(sessions) {
|
|
641
|
+
// Half-open [start, end) intervals in minutes-of-week, wrapping split at the
|
|
642
|
+
// week boundary so the merge below is plain linear-interval arithmetic.
|
|
643
|
+
const intervals = [];
|
|
644
|
+
for (const session of sessions) {
|
|
645
|
+
const openTime = parseHHMM(session.open);
|
|
646
|
+
const closeTime = parseHHMM(session.close);
|
|
647
|
+
const openMinutes = openTime.hour * 60 + openTime.minute;
|
|
648
|
+
const closeMinutes = closeTime.hour * 60 + closeTime.minute;
|
|
649
|
+
// Duration in minutes: a same-day session is the plain difference; a
|
|
650
|
+
// cross-day one (close <= open) runs into the next day; equal times are
|
|
651
|
+
// the forex 24h session.
|
|
652
|
+
const duration = closeMinutes > openMinutes
|
|
653
|
+
? closeMinutes - openMinutes
|
|
654
|
+
: MINUTES_PER_DAY - openMinutes + closeMinutes;
|
|
655
|
+
for (const day of session.days) {
|
|
656
|
+
const start = minuteOfWeek(day, openTime.hour, openTime.minute);
|
|
657
|
+
const end = start + duration;
|
|
658
|
+
if (end <= MINUTES_PER_WEEK) {
|
|
659
|
+
intervals.push({ start, end });
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
intervals.push({ start, end: MINUTES_PER_WEEK });
|
|
663
|
+
intervals.push({ start: 0, end: end - MINUTES_PER_WEEK });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// No coverage at all. Distinct from full coverage below, and the two used to
|
|
668
|
+
// be indistinguishable — both returned an empty event list, so the caller
|
|
669
|
+
// guessed from `sessions.length` and got it wrong for a session whose `days`
|
|
670
|
+
// is empty: a venue that never opens read as 24/7 tradable.
|
|
671
|
+
if (intervals.length === 0)
|
|
672
|
+
return { events: [], alwaysOpen: false };
|
|
673
|
+
intervals.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
674
|
+
const merged = [];
|
|
675
|
+
for (const iv of intervals) {
|
|
676
|
+
const last = merged[merged.length - 1];
|
|
677
|
+
// `<=` (not `<`) merges TOUCHING intervals too: a close and an open at the
|
|
678
|
+
// same minute is one continuous stretch, not a zero-length gap.
|
|
679
|
+
if (last !== undefined && iv.start <= last.end) {
|
|
680
|
+
if (iv.end > last.end)
|
|
681
|
+
last.end = iv.end;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
merged.push({ ...iv });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// The split above can leave a run ending at the week boundary and another
|
|
688
|
+
// starting at 0; on the circle those are one stretch.
|
|
689
|
+
const first = merged[0];
|
|
690
|
+
const last = merged[merged.length - 1];
|
|
691
|
+
if (merged.length > 1 && first.start === 0 && last.end === MINUTES_PER_WEEK) {
|
|
692
|
+
first.start = last.start - MINUTES_PER_WEEK;
|
|
693
|
+
merged.pop();
|
|
694
|
+
}
|
|
695
|
+
// Fully covered week ⇒ never closes. An empty event list is how
|
|
696
|
+
// `computeScheduledStatus` recognises 24/7 (and still applies holidays).
|
|
697
|
+
if (merged.length === 1 && merged[0].end - merged[0].start >= MINUTES_PER_WEEK) {
|
|
698
|
+
return { events: [], alwaysOpen: true };
|
|
699
|
+
}
|
|
700
|
+
const events = [];
|
|
701
|
+
for (const iv of merged) {
|
|
702
|
+
events.push({
|
|
703
|
+
minuteOfWeek: ((iv.start % MINUTES_PER_WEEK) + MINUTES_PER_WEEK) % MINUTES_PER_WEEK,
|
|
704
|
+
type: "open",
|
|
705
|
+
});
|
|
706
|
+
events.push({ minuteOfWeek: iv.end % MINUTES_PER_WEEK, type: "close" });
|
|
707
|
+
}
|
|
708
|
+
events.sort((a, b) => a.minuteOfWeek - b.minuteOfWeek || (a.type === "close" ? -1 : 1));
|
|
709
|
+
return { events, alwaysOpen: false };
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* The SCHEDULED status at a minute-of-week — holidays not considered.
|
|
713
|
+
*
|
|
714
|
+
* Boundary rule: an open minute IS open (inclusive), a close minute IS closed
|
|
715
|
+
* (exclusive). Extracted because the holiday logic needs to ask the same
|
|
716
|
+
* question about a FUTURE instant (is the venue mid-session when a holiday
|
|
717
|
+
* lifts?), and answering it two different ways is how the two disagreed.
|
|
718
|
+
*/
|
|
719
|
+
/** `minuteOfWeek` for an already-converted local reading. */
|
|
720
|
+
function minuteOfWeekOf(parts) {
|
|
721
|
+
return minuteOfWeek(parts.dayOfWeek, parts.hour, parts.minute);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Ms from `now` to the end of the holiday run covering `from` — but only when
|
|
725
|
+
* the venue is SCHEDULED-OPEN at that instant, i.e. it resumes mid-session.
|
|
726
|
+
*
|
|
727
|
+
* `null` when the holiday run ends outside the lookahead, or when the venue is
|
|
728
|
+
* scheduled-closed then (so the reopen is a later `open` event, not this
|
|
729
|
+
* boundary).
|
|
730
|
+
*
|
|
731
|
+
* Both holiday arms need this. A venue is closed on a holiday even mid-session,
|
|
732
|
+
* so it comes back the moment the holiday lifts — which for any session
|
|
733
|
+
* spanning local midnight is NOT the next `open` event. Asking only about
|
|
734
|
+
* `open` events reported the reopening up to a full session late, whether the
|
|
735
|
+
* holiday is happening now or is still ahead.
|
|
736
|
+
*/
|
|
737
|
+
function midSessionReopen(events, holidaySet, now, timezone,
|
|
738
|
+
/** The instant inside the holiday run to measure from (`now`, or a future open). */
|
|
739
|
+
from) {
|
|
740
|
+
const base = new Date(now.getTime() + from.msFromNow);
|
|
741
|
+
const untilLift = msUntilLocalDayStart(base, timezone, holidaySet, false, from.at);
|
|
742
|
+
if (untilLift === null)
|
|
743
|
+
return null;
|
|
744
|
+
const liftsIn = from.msFromNow + untilLift;
|
|
745
|
+
const liftsAt = toLocalParts(new Date(now.getTime() + liftsIn), timezone);
|
|
746
|
+
return scheduledStatusAt(events, minuteOfWeekOf(liftsAt)) === "open" ? liftsIn : null;
|
|
747
|
+
}
|
|
748
|
+
function scheduledStatusAt(events, mow) {
|
|
749
|
+
const exact = events.find((e) => e.minuteOfWeek === mow);
|
|
750
|
+
if (exact)
|
|
751
|
+
return exact.type === "open" ? "open" : "closed";
|
|
752
|
+
const afterIdx = events.findIndex((e) => e.minuteOfWeek > mow);
|
|
753
|
+
const prev = events[((afterIdx === -1 ? 0 : afterIdx) - 1 + events.length) % events.length];
|
|
754
|
+
return prev?.type === "open" ? "open" : "closed";
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Status on a date whose MODIFIED hours replace the schedule, or `null` once
|
|
758
|
+
* the last window has passed (shut for the rest of the day — the caller's
|
|
759
|
+
* holiday walk then owns the answer).
|
|
760
|
+
*
|
|
761
|
+
* Windows are minutes-from-midnight and day-scoped, so this is plain arithmetic
|
|
762
|
+
* against the local time of day; a `24:00` end stays distinct from `00:00`.
|
|
763
|
+
*/
|
|
764
|
+
function modifiedHoursStatus(windows, local) {
|
|
765
|
+
const intoDay = minutesIntoLocalDay(local);
|
|
766
|
+
const openNow = windows.find((w) => intoDay >= w.start && intoDay < w.end);
|
|
767
|
+
if (openNow !== undefined) {
|
|
768
|
+
return { status: "open", nextStatusChangeIn: (openNow.end - intoDay) * MS_PER_MINUTE };
|
|
769
|
+
}
|
|
770
|
+
const laterToday = windows.find((w) => w.start > intoDay);
|
|
771
|
+
if (laterToday !== undefined) {
|
|
772
|
+
return { status: "closed", nextStatusChangeIn: (laterToday.start - intoDay) * MS_PER_MINUTE };
|
|
773
|
+
}
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
function computeScheduledStatus(tradingHours, now) {
|
|
777
|
+
const { events, alwaysOpen, holidays: holidaySet, holidayHours } = derive(tradingHours);
|
|
778
|
+
// An EMPTY event list has two opposite meanings, and collapsing them to
|
|
779
|
+
// "open" reported a permanently-closed venue as tradable:
|
|
780
|
+
// - NO coverage (no sessions, or sessions that name no days) ⇒ the venue
|
|
781
|
+
// never opens;
|
|
782
|
+
// - coverage spanning the whole week (a 24/7 schedule) ⇒ it never closes —
|
|
783
|
+
// but its HOLIDAYS still mask it, which an early return would skip.
|
|
784
|
+
// `buildWeeklyEvents` reports which via `alwaysOpen`; inferring it from
|
|
785
|
+
// `sessions.length` mis-read a session whose `days` is empty as 24/7.
|
|
786
|
+
// This arm reads no clock at all, so `local` is derived below it.
|
|
787
|
+
if (events.length === 0 && !alwaysOpen) {
|
|
788
|
+
return { status: "closed", nextStatusChangeIn: null };
|
|
789
|
+
}
|
|
790
|
+
const local = toLocalParts(now, tradingHours.timezone);
|
|
791
|
+
// MODIFIED HOURS replace the weekly schedule for this date — an early close
|
|
792
|
+
// or a split session. This runs before EVERY other arm, the 24/7 one
|
|
793
|
+
// included: a continuous venue with an early close is closed outside those
|
|
794
|
+
// windows, and evaluating the 24/7 arm first reported it shut for the whole
|
|
795
|
+
// day instead (it only knows full closures). Falling through means the last
|
|
796
|
+
// window has passed, i.e. shut for the rest of the day, which the holiday
|
|
797
|
+
// walks below answer correctly.
|
|
798
|
+
const modifiedToday = holidayHours.get(holidayKey(local.month, local.day));
|
|
799
|
+
if (modifiedToday !== undefined) {
|
|
800
|
+
const within = modifiedHoursStatus(modifiedToday, local);
|
|
801
|
+
if (within !== null)
|
|
802
|
+
return within;
|
|
803
|
+
}
|
|
804
|
+
if (events.length === 0) {
|
|
805
|
+
// Continuous 24/7: only a holiday can change the status.
|
|
806
|
+
const onHoliday = isHoliday(holidaySet, local);
|
|
807
|
+
return {
|
|
808
|
+
status: onHoliday ? "closed" : "open",
|
|
809
|
+
nextStatusChangeIn: holidaySet.size === 0
|
|
810
|
+
? null
|
|
811
|
+
: msUntilLocalDayStart(now, tradingHours.timezone, holidaySet, !onHoliday, local),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
const nowMow = minuteOfWeek(local.dayOfWeek, local.hour, local.minute);
|
|
815
|
+
if (isHoliday(holidaySet, local)) {
|
|
816
|
+
// The venue reopens the moment it is BOTH off-holiday and scheduled-open.
|
|
817
|
+
// When the holiday lifts mid-session — a daily 18:00→17:00 venue on the
|
|
818
|
+
// morning after — that instant is the holiday's end at local midnight, not
|
|
819
|
+
// the next scheduled `open` event. Looking only for the next non-holiday
|
|
820
|
+
// open reported the reopening up to a full session late.
|
|
821
|
+
const midSession = midSessionReopen(events, holidaySet, now, tradingHours.timezone, {
|
|
822
|
+
at: local,
|
|
823
|
+
msFromNow: 0,
|
|
824
|
+
});
|
|
825
|
+
const viaSchedule = midSession ?? findNextNonHolidayOpen(events, holidaySet, now, tradingHours.timezone, local);
|
|
826
|
+
return {
|
|
827
|
+
status: "closed",
|
|
828
|
+
nextStatusChangeIn: soonest(viaSchedule, msUntilNextModifiedOpen(now, tradingHours.timezone, holidayHours, local)),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
// Determine current status and find next event.
|
|
832
|
+
// Walk through events to find where `nowMow` falls.
|
|
833
|
+
//
|
|
834
|
+
// The events array is sorted. We need to find the next event AFTER nowMow.
|
|
835
|
+
// If we're at or past an open event but before the next close → open.
|
|
836
|
+
// If we're at or past a close event but before the next open → closed.
|
|
837
|
+
//
|
|
838
|
+
// Boundary rule: open time IS open (inclusive), close time IS closed (exclusive).
|
|
839
|
+
// So at exactly open → status=open (next event is close).
|
|
840
|
+
// at exactly close → status=closed (next event is open).
|
|
841
|
+
// Find the first event strictly after nowMow
|
|
842
|
+
let nextIdx = events.findIndex((e) => e.minuteOfWeek > nowMow);
|
|
843
|
+
if (nextIdx === -1) {
|
|
844
|
+
// We're past all events this week → wrap around to first event next week
|
|
845
|
+
nextIdx = 0;
|
|
846
|
+
}
|
|
847
|
+
const currentStatus = scheduledStatusAt(events, nowMow);
|
|
848
|
+
// Find the next event that represents a STATUS CHANGE — if we're open, the
|
|
849
|
+
// next 'close'; if closed, the next 'open' — scanning forward from `nextIdx`
|
|
850
|
+
// and wrapping into next week.
|
|
851
|
+
const targetType = currentStatus === "open" ? "close" : "open";
|
|
852
|
+
const nextChangeEvent = [...events.slice(nextIdx), ...events.slice(0, nextIdx)].find((candidate) => candidate.type === targetType);
|
|
853
|
+
if (nextChangeEvent === undefined) {
|
|
854
|
+
// Should not happen with valid schedules
|
|
855
|
+
return { status: currentStatus, nextStatusChangeIn: null };
|
|
856
|
+
}
|
|
857
|
+
// Local minute-of-week → a real instant (DST-correct; see the helper). The
|
|
858
|
+
// converged local reading comes back with it, so the holiday test below
|
|
859
|
+
// needs no second `Intl` pass.
|
|
860
|
+
const next = nextLocalMinuteOfWeek(now, tradingHours.timezone, nextChangeEvent.minuteOfWeek, 0, local);
|
|
861
|
+
let nextStatusChangeIn = next.ms;
|
|
862
|
+
if (holidaySet.size > 0) {
|
|
863
|
+
if (currentStatus === "open") {
|
|
864
|
+
// A holiday's local midnight is itself a status change: an open venue
|
|
865
|
+
// closes when the holiday STARTS, even mid-session, which is earlier than
|
|
866
|
+
// the scheduled close whenever a holiday falls inside the session. Only
|
|
867
|
+
// the `open`-lands-on-a-holiday case used to be handled, so an open venue
|
|
868
|
+
// the night before a holiday reported its close hours late.
|
|
869
|
+
const holidayStarts = msUntilLocalDayStart(now, tradingHours.timezone, holidaySet, true, local);
|
|
870
|
+
if (holidayStarts !== null && holidayStarts < nextStatusChangeIn) {
|
|
871
|
+
nextStatusChangeIn = holidayStarts;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
else if (nextChangeEvent.type === "open" && isHoliday(holidaySet, next.at)) {
|
|
875
|
+
// The upcoming open lands on a date with MODIFIED hours: the venue does
|
|
876
|
+
// open that day, just at its replacement time. Treating the date as a
|
|
877
|
+
// plain holiday skipped the whole session and reported the next NORMAL
|
|
878
|
+
// trading day instead.
|
|
879
|
+
const modifiedThen = holidayHours.get(holidayKey(next.at.month, next.at.day));
|
|
880
|
+
if (modifiedThen !== undefined && modifiedThen.length > 0) {
|
|
881
|
+
// Midnight of that local date, plus the first window's start.
|
|
882
|
+
const midnight = next.ms - minutesIntoLocalDay(next.at) * MS_PER_MINUTE;
|
|
883
|
+
const opensIn = midnight + modifiedThen[0].start * MS_PER_MINUTE;
|
|
884
|
+
if (opensIn > 0) {
|
|
885
|
+
nextStatusChangeIn = opensIn;
|
|
886
|
+
return { status: currentStatus, nextStatusChangeIn };
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// Closed by schedule, and the upcoming open lands on a holiday.
|
|
890
|
+
//
|
|
891
|
+
// The masked session may still be RUNNING when that holiday lifts — a
|
|
892
|
+
// Monday 18:00→17:00 session on a Monday holiday resumes at Tuesday
|
|
893
|
+
// midnight, not at the following Monday's open. Skipping straight to the
|
|
894
|
+
// next non-holiday `open` reported that reopening a week late.
|
|
895
|
+
const midSession = midSessionReopen(events, holidaySet, now, tradingHours.timezone, {
|
|
896
|
+
at: next.at,
|
|
897
|
+
msFromNow: next.ms,
|
|
898
|
+
});
|
|
899
|
+
const skipped = midSession ?? findNextNonHolidayOpen(events, holidaySet, now, tradingHours.timezone, local);
|
|
900
|
+
if (skipped !== null)
|
|
901
|
+
nextStatusChangeIn = skipped;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (currentStatus === "closed") {
|
|
905
|
+
// A modified-hours session can sit on a day the weekly schedule never
|
|
906
|
+
// opens, so it has no `open` event for the walk above to find. Take
|
|
907
|
+
// whichever comes first.
|
|
908
|
+
nextStatusChangeIn =
|
|
909
|
+
soonest(nextStatusChangeIn, msUntilNextModifiedOpen(now, tradingHours.timezone, holidayHours, local)) ?? nextStatusChangeIn;
|
|
910
|
+
}
|
|
911
|
+
return { status: currentStatus, nextStatusChangeIn };
|
|
912
|
+
}
|
|
913
|
+
function holidayKey(month, day) {
|
|
914
|
+
return month * 100 + day;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Replacement windows for a modified-hours holiday, as minutes-from-midnight.
|
|
918
|
+
* An empty array means a FULL closure (`MMDD/C`).
|
|
919
|
+
*/
|
|
920
|
+
function holidayWindows(entry) {
|
|
921
|
+
return (entry.sessions ?? []).map((w) => {
|
|
922
|
+
const open = parseHHMM(w.open);
|
|
923
|
+
const close = parseHHMM(w.close);
|
|
924
|
+
return { start: open.hour * 60 + open.minute, end: close.hour * 60 + close.minute };
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/** Only the dates that REPLACE the schedule, keyed like {@link buildHolidaySet}. */
|
|
928
|
+
function buildHolidayHours(holidays) {
|
|
929
|
+
const map = new Map();
|
|
930
|
+
for (const h of holidays ?? []) {
|
|
931
|
+
const windows = holidayWindows(h);
|
|
932
|
+
if (windows.length > 0)
|
|
933
|
+
map.set(holidayKey(h.month, h.day), windows);
|
|
934
|
+
}
|
|
935
|
+
return map;
|
|
936
|
+
}
|
|
937
|
+
function buildHolidaySet(holidays) {
|
|
938
|
+
const set = new Set();
|
|
939
|
+
if (holidays) {
|
|
940
|
+
for (const h of holidays)
|
|
941
|
+
set.add(holidayKey(h.month, h.day));
|
|
942
|
+
}
|
|
943
|
+
return set;
|
|
944
|
+
}
|
|
945
|
+
function isHoliday(holidaySet, parts) {
|
|
946
|
+
return holidaySet.has(holidayKey(parts.month, parts.day));
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Walk `open` events chronologically up to 3 weeks ahead and return the
|
|
950
|
+
* ms-delta to the first one that doesn't land on a holiday. The lookahead
|
|
951
|
+
* window covers any plausible cluster of consecutive holidays. Returns
|
|
952
|
+
* `null` if no qualifying open is found.
|
|
953
|
+
*/
|
|
954
|
+
function findNextNonHolidayOpen(events, holidaySet, now, timezone,
|
|
955
|
+
/** `now`'s local reading, already derived by the caller. */
|
|
956
|
+
local) {
|
|
957
|
+
const opens = events.filter((e) => e.type === "open");
|
|
958
|
+
if (opens.length === 0)
|
|
959
|
+
return null;
|
|
960
|
+
// Chronological order is knowable WITHOUT resolving anything: within a week
|
|
961
|
+
// the opens are ordered by how far ahead their minute-of-week sits, and the
|
|
962
|
+
// weeks are already in order. So sort by that offset once and resolve
|
|
963
|
+
// lazily — the common case answers on the first candidate instead of
|
|
964
|
+
// resolving every open for three weeks (each resolution costs 1-3
|
|
965
|
+
// `Intl.formatToParts`, and this runs per market on a markets-list request).
|
|
966
|
+
//
|
|
967
|
+
// Resolution goes through `occurrence` rather than adding a fixed week of
|
|
968
|
+
// milliseconds: a calendar week spanning a DST change is not 604_800_000 ms,
|
|
969
|
+
// and the instant feeds straight back into the holiday test, where an hour's
|
|
970
|
+
// drift can land on the wrong local date.
|
|
971
|
+
const nowMow = minuteOfWeek(local.dayOfWeek, local.hour, local.minute);
|
|
972
|
+
const ordered = [...opens].sort((a, b) => {
|
|
973
|
+
const da = minutesUntilNextMow(nowMow, a.minuteOfWeek);
|
|
974
|
+
const db = minutesUntilNextMow(nowMow, b.minuteOfWeek);
|
|
975
|
+
return da - db;
|
|
976
|
+
});
|
|
977
|
+
for (let week = 0; week < LOOKAHEAD_WEEKS; week++) {
|
|
978
|
+
for (const ev of ordered) {
|
|
979
|
+
const candidate = nextLocalMinuteOfWeek(now, timezone, ev.minuteOfWeek, week, local);
|
|
980
|
+
if (!isHoliday(holidaySet, candidate.at))
|
|
981
|
+
return candidate.ms;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return null;
|
|
985
|
+
}
|