@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.
@@ -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()