@v587d/capital-generation 2.12.0 → 2.13.1
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/CHANGELOG.md +29 -0
- package/README.md +70 -70
- package/automation/builtins.js +147 -0
- package/automation/calendar-source.js +361 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +351 -0
- package/automation/client.src.cjs +2734 -0
- package/automation/defaults.js +75 -0
- package/automation/index.js +188 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +51 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +912 -0
- package/automation/scheduler.js +513 -0
- package/automation/schema.js +375 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +732 -0
- package/automation/time.js +220 -0
- package/automation/workspaces.js +154 -0
- package/chart-ui/package.json +1 -1
- package/config/client.js +4 -2
- package/config/client.src.cjs +90 -1
- package/config/index.js +15 -0
- package/config/package.json +1 -1
- package/cordis.patch.yml +8 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +16 -0
- package/lib/sources/wind-mcp.js +3 -3
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +5 -2
- package/watchlist/package.json +1 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler calendar backed by the exchange's published closure calendar (SSE/SZSE share it),
|
|
3
|
+
* fetched from SZSE's keyless month JSON. Fetched months win over the bundled table; an
|
|
4
|
+
* unpublished month falls back to it, and an uncovered date stays `undefined` (fail-closed).
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { createTradingCalendar } from './calendar.js'
|
|
9
|
+
import { sanitizeMessage } from './redact.js'
|
|
10
|
+
|
|
11
|
+
const SZSE_MONTH_URL = 'https://www.szse.cn/api/report/exchange/onepersistenthour/monthList'
|
|
12
|
+
const CACHE_VERSION = 1
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 8_000
|
|
14
|
+
const DEFAULT_REFRESH_MS = 24 * 60 * 60_000
|
|
15
|
+
const FAILURE_RETRY_MS = 30 * 60_000
|
|
16
|
+
const UNPUBLISHED_RETRY_MS = 6 * 60 * 60_000
|
|
17
|
+
const INITIAL_WAIT_MS = 5_000
|
|
18
|
+
const DEFAULT_HORIZON_DAYS = 400
|
|
19
|
+
const BACKFILL_DAYS = 62
|
|
20
|
+
const CONCURRENCY = 4
|
|
21
|
+
const MAX_BODY_CHARS = 256 * 1024
|
|
22
|
+
const OFFSET_SHANGHAI_MS = 8 * 60 * 60_000
|
|
23
|
+
const MONTH_PATTERN = /^\d{4}-(?:0[1-9]|1[0-2])$/
|
|
24
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
25
|
+
const USER_AGENT = '@v587d/capital-generation'
|
|
26
|
+
|
|
27
|
+
function shanghaiDate(instant) {
|
|
28
|
+
return new Date(instant + OFFSET_SHANGHAI_MS).toISOString().slice(0, 10)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function monthOf(date) {
|
|
32
|
+
return date.slice(0, 7)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function daysInMonth(month) {
|
|
36
|
+
const [year, value] = month.split('-').map(Number)
|
|
37
|
+
return new Date(Date.UTC(year, value, 0)).getUTCDate()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addDays(date, delta) {
|
|
41
|
+
const [year, month, day] = date.split('-').map(Number)
|
|
42
|
+
const next = new Date(Date.UTC(year, month - 1, day + delta))
|
|
43
|
+
return next.toISOString().slice(0, 10)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function monthRange(from, to) {
|
|
47
|
+
const months = []
|
|
48
|
+
let [year, month] = from.split('-').map(Number)
|
|
49
|
+
const [lastYear, lastMonth] = to.split('-').map(Number)
|
|
50
|
+
while (year < lastYear || (year === lastYear && month <= lastMonth)) {
|
|
51
|
+
months.push(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}`)
|
|
52
|
+
month += 1
|
|
53
|
+
if (month > 12) {
|
|
54
|
+
month = 1
|
|
55
|
+
year += 1
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return months
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function delay(ms) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const timer = setTimeout(resolve, ms)
|
|
64
|
+
timer.unref?.()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {{kind:'ok',days:Map<string,boolean>}|{kind:'unpublished'}|{kind:'invalid',reason:string}} */
|
|
69
|
+
function parseMonth(month, body) {
|
|
70
|
+
if (body === null || typeof body !== 'object' || !Array.isArray(body.data)) {
|
|
71
|
+
return { kind: 'invalid', reason: 'response shape' }
|
|
72
|
+
}
|
|
73
|
+
const rows = body.data
|
|
74
|
+
if (rows.length === 0) return { kind: 'unpublished' }
|
|
75
|
+
if (rows.length !== daysInMonth(month)) return { kind: 'invalid', reason: `expected ${daysInMonth(month)} rows, got ${rows.length}` }
|
|
76
|
+
const days = new Map()
|
|
77
|
+
let trading = 0
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
const date = row?.jyrq
|
|
80
|
+
if (typeof date !== 'string' || !DATE_PATTERN.test(date) || monthOf(date) !== month) {
|
|
81
|
+
return { kind: 'invalid', reason: 'row date does not match month' }
|
|
82
|
+
}
|
|
83
|
+
if (days.has(date)) return { kind: 'invalid', reason: 'duplicate row' }
|
|
84
|
+
const flag = row?.jybz
|
|
85
|
+
if (flag !== '0' && flag !== '1') return { kind: 'invalid', reason: 'row flag is not 0/1' }
|
|
86
|
+
days.set(date, flag === '1')
|
|
87
|
+
if (flag === '1') trading += 1
|
|
88
|
+
}
|
|
89
|
+
if (trading === 0) return { kind: 'invalid', reason: 'no trading day in month' }
|
|
90
|
+
return { kind: 'ok', days }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchMonth(month, options) {
|
|
94
|
+
const { fetchImpl, timeoutMs, signal } = options
|
|
95
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
96
|
+
const combined = signal !== undefined && typeof AbortSignal.any === 'function'
|
|
97
|
+
? AbortSignal.any([signal, timeout])
|
|
98
|
+
: timeout
|
|
99
|
+
const response = await fetchImpl(`${SZSE_MONTH_URL}?month=${encodeURIComponent(month)}`, {
|
|
100
|
+
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
|
|
101
|
+
signal: combined,
|
|
102
|
+
})
|
|
103
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
104
|
+
const type = response.headers?.get?.('content-type') ?? ''
|
|
105
|
+
if (!type.includes('json')) throw new Error(`unexpected content-type ${type || '(none)'}`)
|
|
106
|
+
const text = await response.text()
|
|
107
|
+
if (text.length > MAX_BODY_CHARS) throw new Error('response too large')
|
|
108
|
+
return parseMonth(month, JSON.parse(text))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function mapLimit(items, limit, work) {
|
|
112
|
+
const results = new Array(items.length)
|
|
113
|
+
let index = 0
|
|
114
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
115
|
+
while (index < items.length) {
|
|
116
|
+
const current = index
|
|
117
|
+
index += 1
|
|
118
|
+
results[current] = await work(items[current])
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
await Promise.all(workers)
|
|
122
|
+
return results
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {object} [options]
|
|
127
|
+
* @param {typeof fetch} [options.fetch]
|
|
128
|
+
* @param {() => number} [options.now]
|
|
129
|
+
* @param {string} [options.cacheFile]
|
|
130
|
+
* @param {string} [options.cacheDir]
|
|
131
|
+
* @param {ReturnType<typeof createTradingCalendar>} [options.fallback]
|
|
132
|
+
* @param {{warn?: Function, info?: Function}} [options.logger]
|
|
133
|
+
*/
|
|
134
|
+
export function createCalendarSource(options = {}) {
|
|
135
|
+
const fetchImpl = options.fetch ?? ((...args) => globalThis.fetch(...args))
|
|
136
|
+
const now = options.now ?? (() => Date.now())
|
|
137
|
+
const fallback = options.fallback ?? createTradingCalendar()
|
|
138
|
+
const logger = options.logger
|
|
139
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
140
|
+
const refreshMs = options.refreshMs ?? DEFAULT_REFRESH_MS
|
|
141
|
+
const horizonDays = options.horizonDays ?? DEFAULT_HORIZON_DAYS
|
|
142
|
+
const cacheFile = options.cacheFile ?? (options.cacheDir === undefined ? undefined : join(options.cacheDir, 'szse.json'))
|
|
143
|
+
|
|
144
|
+
/** @type {Map<string, {days: Map<string, boolean>, fetchedAt: number}>} */
|
|
145
|
+
const remote = new Map()
|
|
146
|
+
let loaded = false
|
|
147
|
+
let loading
|
|
148
|
+
let refreshing
|
|
149
|
+
let lastAttemptAt = 0
|
|
150
|
+
let lastError = null
|
|
151
|
+
let disposed = false
|
|
152
|
+
let controller
|
|
153
|
+
|
|
154
|
+
function targetMonths() {
|
|
155
|
+
const today = shanghaiDate(now())
|
|
156
|
+
return monthRange(monthOf(addDays(today, -BACKFILL_DAYS)), monthOf(addDays(today, horizonDays)))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isTradingDay(date) {
|
|
160
|
+
if (typeof date !== 'string' || !DATE_PATTERN.test(date)) return undefined
|
|
161
|
+
const entry = remote.get(monthOf(date))
|
|
162
|
+
if (entry !== undefined && entry.days.size > 0) {
|
|
163
|
+
if (entry.days.has(date)) return entry.days.get(date) === true
|
|
164
|
+
}
|
|
165
|
+
return fallback.isTradingDay(date)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fetchedAtOf() {
|
|
169
|
+
let latest
|
|
170
|
+
for (const entry of remote.values()) {
|
|
171
|
+
if (latest === undefined || entry.fetchedAt > latest) latest = entry.fetchedAt
|
|
172
|
+
}
|
|
173
|
+
return latest
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function status(at = new Date(now())) {
|
|
177
|
+
const instant = at instanceof Date ? at.getTime() : Number(at)
|
|
178
|
+
const today = shanghaiDate(Number.isFinite(instant) ? instant : now())
|
|
179
|
+
const todayKnown = isTradingDay(today) !== undefined
|
|
180
|
+
let through = null
|
|
181
|
+
let firstUnknown = null
|
|
182
|
+
let cursor = today
|
|
183
|
+
for (let index = 0; index < 500; index += 1) {
|
|
184
|
+
if (isTradingDay(cursor) === undefined) {
|
|
185
|
+
firstUnknown = cursor
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
through = cursor
|
|
189
|
+
cursor = addDays(cursor, 1)
|
|
190
|
+
}
|
|
191
|
+
const entry = remote.get(monthOf(today))
|
|
192
|
+
const source = entry !== undefined && entry.days.has(today) ? 'szse' : todayKnown ? 'bundled' : 'none'
|
|
193
|
+
const fetchedAt = fetchedAtOf()
|
|
194
|
+
const coverageDays = through === null
|
|
195
|
+
? 0
|
|
196
|
+
: Math.max(0, Math.round((Date.parse(`${through}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000))
|
|
197
|
+
return {
|
|
198
|
+
available: todayKnown,
|
|
199
|
+
source,
|
|
200
|
+
through,
|
|
201
|
+
firstUnknown,
|
|
202
|
+
coverageDays,
|
|
203
|
+
fetchedAt: fetchedAt === undefined ? null : new Date(fetchedAt).toISOString(),
|
|
204
|
+
stale: fetchedAt !== undefined && now() - fetchedAt > refreshMs * 2,
|
|
205
|
+
refreshing: refreshing !== undefined,
|
|
206
|
+
lastError: lastError === null ? null : lastError,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function load() {
|
|
211
|
+
if (loading !== undefined) return loading
|
|
212
|
+
if (loaded) return
|
|
213
|
+
loaded = true
|
|
214
|
+
if (cacheFile === undefined) return
|
|
215
|
+
loading = (async () => {
|
|
216
|
+
let text
|
|
217
|
+
try {
|
|
218
|
+
text = await readFile(cacheFile, 'utf8')
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error?.code !== 'ENOENT') logger?.warn?.(`capital-automation: 交易日历缓存读取失败:${sanitizeMessage(String(error))}`)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
let data
|
|
224
|
+
try {
|
|
225
|
+
data = JSON.parse(text)
|
|
226
|
+
} catch {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (data?.version !== CACHE_VERSION || data.months === null || typeof data.months !== 'object') return
|
|
230
|
+
for (const [month, entry] of Object.entries(data.months)) {
|
|
231
|
+
if (!MONTH_PATTERN.test(month) || entry === null || typeof entry !== 'object') continue
|
|
232
|
+
const fetchedAt = Date.parse(entry.fetchedAt)
|
|
233
|
+
if (!Number.isFinite(fetchedAt)) continue
|
|
234
|
+
const days = new Map()
|
|
235
|
+
const rows = entry.days !== null && typeof entry.days === 'object' ? entry.days : {}
|
|
236
|
+
for (const [date, flag] of Object.entries(rows)) {
|
|
237
|
+
if (!DATE_PATTERN.test(date) || monthOf(date) !== month) continue
|
|
238
|
+
if (flag !== 0 && flag !== 1) continue
|
|
239
|
+
days.set(date, flag === 1)
|
|
240
|
+
}
|
|
241
|
+
if (days.size !== 0 && days.size !== daysInMonth(month)) continue
|
|
242
|
+
remote.set(month, { days, fetchedAt })
|
|
243
|
+
}
|
|
244
|
+
})().finally(() => { loading = undefined })
|
|
245
|
+
return loading
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function writeCache() {
|
|
249
|
+
if (cacheFile === undefined || disposed) return
|
|
250
|
+
const months = {}
|
|
251
|
+
for (const [month, entry] of remote) {
|
|
252
|
+
months[month] = {
|
|
253
|
+
fetchedAt: new Date(entry.fetchedAt).toISOString(),
|
|
254
|
+
days: Object.fromEntries([...entry.days].map(([date, trading]) => [date, trading ? 1 : 0])),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const temporary = `${cacheFile}.${process.pid}.tmp`
|
|
258
|
+
try {
|
|
259
|
+
await mkdir(dirname(cacheFile), { recursive: true })
|
|
260
|
+
await writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, savedAt: new Date(now()).toISOString(), months }), 'utf8')
|
|
261
|
+
await rename(temporary, cacheFile)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
logger?.warn?.(`capital-automation: 交易日历缓存写入失败:${sanitizeMessage(String(error))}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function refresh({ force = false } = {}) {
|
|
268
|
+
if (disposed) return status()
|
|
269
|
+
if (refreshing !== undefined) return refreshing
|
|
270
|
+
refreshing = (async () => {
|
|
271
|
+
await load()
|
|
272
|
+
if (disposed) return status()
|
|
273
|
+
const stamp = now()
|
|
274
|
+
const pending = targetMonths().filter((month) => {
|
|
275
|
+
const entry = remote.get(month)
|
|
276
|
+
if (force) return true
|
|
277
|
+
if (entry === undefined) return true
|
|
278
|
+
const age = stamp - entry.fetchedAt
|
|
279
|
+
return entry.days.size === 0 ? age >= UNPUBLISHED_RETRY_MS : age >= refreshMs
|
|
280
|
+
})
|
|
281
|
+
let changed = false
|
|
282
|
+
let failures = 0
|
|
283
|
+
if (pending.length > 0) {
|
|
284
|
+
controller = new AbortController()
|
|
285
|
+
const signal = controller.signal
|
|
286
|
+
const results = await mapLimit(pending, CONCURRENCY, async (month) => {
|
|
287
|
+
try {
|
|
288
|
+
return { month, parsed: await fetchMonth(month, { fetchImpl, timeoutMs, signal }) }
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return { month, error }
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
if (disposed) return status()
|
|
294
|
+
for (const result of results) {
|
|
295
|
+
if (result.error !== undefined) {
|
|
296
|
+
failures += 1
|
|
297
|
+
lastError = `${result.month}: ${sanitizeMessage(String(result.error?.message ?? result.error))}`
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if (result.parsed.kind === 'ok') {
|
|
301
|
+
remote.set(result.month, { days: result.parsed.days, fetchedAt: stamp })
|
|
302
|
+
changed = true
|
|
303
|
+
} else if (result.parsed.kind === 'unpublished') {
|
|
304
|
+
remote.set(result.month, { days: new Map(), fetchedAt: stamp })
|
|
305
|
+
changed = true
|
|
306
|
+
} else {
|
|
307
|
+
failures += 1
|
|
308
|
+
lastError = `${result.month}: ${result.parsed.reason}`
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (failures === 0) lastError = null
|
|
312
|
+
} else {
|
|
313
|
+
lastError = null
|
|
314
|
+
}
|
|
315
|
+
lastAttemptAt = now()
|
|
316
|
+
if (changed) {
|
|
317
|
+
await writeCache()
|
|
318
|
+
const snapshot = status()
|
|
319
|
+
logger?.info?.(`capital-automation: 交易日历已刷新(${snapshot.source},覆盖至 ${snapshot.through ?? '未知'})`)
|
|
320
|
+
}
|
|
321
|
+
return status()
|
|
322
|
+
})().finally(() => { refreshing = undefined })
|
|
323
|
+
return refreshing
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function refreshIfStale() {
|
|
327
|
+
if (disposed || refreshing !== undefined) return
|
|
328
|
+
const stamp = now()
|
|
329
|
+
const interval = lastError === null ? refreshMs : FAILURE_RETRY_MS
|
|
330
|
+
const due = lastAttemptAt === 0 || stamp - lastAttemptAt >= interval
|
|
331
|
+
if (!due) {
|
|
332
|
+
const retry = targetMonths().some((month) => {
|
|
333
|
+
const entry = remote.get(month)
|
|
334
|
+
return entry !== undefined && entry.days.size === 0 && stamp - entry.fetchedAt >= UNPUBLISHED_RETRY_MS
|
|
335
|
+
})
|
|
336
|
+
if (!retry) return
|
|
337
|
+
}
|
|
338
|
+
void refresh().catch(() => {})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function ready() {
|
|
342
|
+
await load()
|
|
343
|
+
const today = shanghaiDate(now())
|
|
344
|
+
if (isTradingDay(today) !== undefined) {
|
|
345
|
+
void refresh().catch(() => {})
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
await Promise.race([refresh().catch(() => {}), delay(INITIAL_WAIT_MS)])
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function pendingRefresh() {
|
|
352
|
+
return refreshing !== undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function dispose() {
|
|
356
|
+
disposed = true
|
|
357
|
+
controller?.abort()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { isTradingDay, status, load, ready, refresh, refreshIfStale, pendingRefresh, dispose }
|
|
361
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed A-share trading calendar. The holiday list is an explicit exchange schedule, not a
|
|
3
|
+
* Mon–Fri guess: a date outside the published window returns `undefined`, and the scheduler
|
|
4
|
+
* records `skipped_calendar_unavailable` instead of running.
|
|
5
|
+
*
|
|
6
|
+
* Sources: SSE 2026 market-closure notice (上证公告〔2025〕45号 + the 2026 mid-autumn/national-day
|
|
7
|
+
* notice). Update this table when the exchange publishes the next year; do not infer holidays.
|
|
8
|
+
*/
|
|
9
|
+
const CN_HOLIDAYS_2026 = new Set([
|
|
10
|
+
// New Year
|
|
11
|
+
'2026-01-01', '2026-01-02', '2026-01-03',
|
|
12
|
+
// Spring Festival
|
|
13
|
+
'2026-02-15', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19',
|
|
14
|
+
'2026-02-20', '2026-02-21', '2026-02-22', '2026-02-23',
|
|
15
|
+
// Qingming
|
|
16
|
+
'2026-04-04', '2026-04-05', '2026-04-06',
|
|
17
|
+
// Labour Day
|
|
18
|
+
'2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
|
|
19
|
+
// Dragon Boat
|
|
20
|
+
'2026-06-19', '2026-06-20', '2026-06-21',
|
|
21
|
+
// Mid-Autumn
|
|
22
|
+
'2026-09-25', '2026-09-26', '2026-09-27',
|
|
23
|
+
// National Day
|
|
24
|
+
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04', '2026-10-05', '2026-10-06', '2026-10-07',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const PUBLISHED = new Map([
|
|
28
|
+
[2026, { holidays: CN_HOLIDAYS_2026, through: '2026-12-31' }],
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function weekdayOf(date) {
|
|
33
|
+
const [year, month, day] = date.split('-').map(Number)
|
|
34
|
+
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
|
|
35
|
+
return weekday === 0 ? 7 : weekday
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createTradingCalendar(options = {}) {
|
|
39
|
+
const published = options.published ?? PUBLISHED
|
|
40
|
+
return {
|
|
41
|
+
isTradingDay(date) {
|
|
42
|
+
const year = Number(String(date).slice(0, 4))
|
|
43
|
+
const entry = published.get(year)
|
|
44
|
+
if (entry === undefined) return undefined
|
|
45
|
+
if (weekdayOf(date) >= 6) return false
|
|
46
|
+
return !entry.holidays.has(date)
|
|
47
|
+
},
|
|
48
|
+
/** The first date the table cannot answer; callers show this instead of pretending. */
|
|
49
|
+
status(at = new Date()) {
|
|
50
|
+
const today = at.toISOString().slice(0, 10)
|
|
51
|
+
if (this.isTradingDay(today) === undefined) {
|
|
52
|
+
return { available: false, source: 'CN-A-share-2026', through: null, firstUnknown: today }
|
|
53
|
+
}
|
|
54
|
+
const years = [...published.keys()].sort((a, b) => a - b)
|
|
55
|
+
const last = years.at(-1)
|
|
56
|
+
return {
|
|
57
|
+
available: true,
|
|
58
|
+
source: 'CN-A-share-2026',
|
|
59
|
+
through: `${last}-12-31`,
|
|
60
|
+
firstUnknown: `${last + 1}-01-01`,
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const tradingCalendar = createTradingCalendar()
|