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