@v587d/capital-generation 2.11.1 → 2.13.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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Occurrence math for daily/weekly/monthly wall-clock triggers. Instants are the only stored
3
+ * form; local dates never become the schedule identity. Nonexistent DST wall times are skipped
4
+ * (the day has no such minute) and ambiguous ones resolve to the first occurrence.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+
8
+ const formatterCache = new Map()
9
+
10
+ function formatterFor(timeZone) {
11
+ let formatter = formatterCache.get(timeZone)
12
+ if (formatter === undefined) {
13
+ formatter = new Intl.DateTimeFormat('en-US', {
14
+ timeZone,
15
+ year: 'numeric',
16
+ month: '2-digit',
17
+ day: '2-digit',
18
+ hour: '2-digit',
19
+ minute: '2-digit',
20
+ second: '2-digit',
21
+ hourCycle: 'h23',
22
+ })
23
+ formatterCache.set(timeZone, formatter)
24
+ }
25
+ return formatter
26
+ }
27
+
28
+ /** @returns {{year:number,month:number,day:number,hour:number,minute:number,second:number,date:string}} */
29
+ export function localPartsAt(instantMs, timeZone) {
30
+ const parts = formatterFor(timeZone).formatToParts(new Date(instantMs))
31
+ const read = (type) => Number(parts.find((part) => part.type === type)?.value)
32
+ const year = read('year')
33
+ const month = read('month')
34
+ const day = read('day')
35
+ return {
36
+ year,
37
+ month,
38
+ day,
39
+ hour: read('hour'),
40
+ minute: read('minute'),
41
+ second: read('second'),
42
+ date: dateString(year, month, day),
43
+ }
44
+ }
45
+
46
+ export function dateString(year, month, day) {
47
+ return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
48
+ }
49
+
50
+ export function parseDateString(value) {
51
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
52
+ if (!match) throw new Error(`invalid date string: ${value}`)
53
+ return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }
54
+ }
55
+
56
+ export function weekdayOf(date) {
57
+ const parsed = typeof date === 'string' ? parseDateString(date) : date
58
+ const day = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)).getUTCDay()
59
+ return day === 0 ? 7 : day
60
+ }
61
+
62
+ export function addDays(date, delta) {
63
+ const parsed = typeof date === 'string' ? parseDateString(date) : date
64
+ const next = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day + delta))
65
+ return {
66
+ year: next.getUTCFullYear(),
67
+ month: next.getUTCMonth() + 1,
68
+ day: next.getUTCDate(),
69
+ date: dateString(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()),
70
+ }
71
+ }
72
+
73
+ function wallAsUtc(date, hour, minute) {
74
+ return Date.UTC(date.year, date.month - 1, date.day, hour, minute, 0, 0)
75
+ }
76
+
77
+ function sameWall(parts, date, hour, minute) {
78
+ return parts.year === date.year
79
+ && parts.month === date.month
80
+ && parts.day === date.day
81
+ && parts.hour === hour
82
+ && parts.minute === minute
83
+ }
84
+
85
+ /**
86
+ * Resolve one wall-clock minute in a zone. The offset probes around the wall minute cover both
87
+ * sides of a transition; the earliest matching instant wins on a fall-back overlap.
88
+ * @returns {number|undefined} epoch ms, or undefined when the local minute does not exist.
89
+ */
90
+ export function zonedWallToInstant(date, hour, minute, timeZone) {
91
+ const parsed = typeof date === 'string' ? parseDateString(date) : date
92
+ const wall = wallAsUtc(parsed, hour, minute)
93
+ const offsets = new Set()
94
+ for (const probe of [wall - 86_400_000, wall, wall + 86_400_000]) {
95
+ const parts = localPartsAt(probe, timeZone)
96
+ offsets.add(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - probe)
97
+ }
98
+ // One refinement pass: a probe may itself sit on the other side of a transition.
99
+ for (const offset of [...offsets]) {
100
+ const parts = localPartsAt(wall - offset, timeZone)
101
+ offsets.add(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - (wall - offset))
102
+ }
103
+ let best
104
+ for (const offset of offsets) {
105
+ const candidate = wall - offset
106
+ if (!sameWall(localPartsAt(candidate, timeZone), parsed, hour, minute)) continue
107
+ if (best === undefined || candidate < best) best = candidate
108
+ }
109
+ return best
110
+ }
111
+
112
+ export function parseClock(time) {
113
+ const [hour, minute] = time.split(':').map(Number)
114
+ return { hour, minute }
115
+ }
116
+
117
+ function matchesCadence(trigger, date) {
118
+ if (trigger.cadence === 'daily') return true
119
+ if (trigger.cadence === 'weekly') return trigger.weekdays.includes(weekdayOf(date))
120
+ const parsed = typeof date === 'string' ? parseDateString(date) : date
121
+ return parsed.day === trigger.dayOfMonth
122
+ }
123
+
124
+ function calendarVerdict(trigger, date, calendar) {
125
+ if (trigger.tradingDaysOnly !== true) return true
126
+ const answer = calendar?.isTradingDay?.(typeof date === 'string' ? date : date.date)
127
+ // Fail closed: absence, a thrown provider, or an out-of-window date never falls back to Mon–Fri.
128
+ return answer === true ? true : answer === false ? false : undefined
129
+ }
130
+
131
+ const DATE_LIMIT = 400
132
+
133
+ /**
134
+ * @returns {{kind:'occurrence',instant:number,scheduledFor:string,localDate:string,scheduleVersion:string}
135
+ * | {kind:'calendar_unavailable',localDate:string}}
136
+ */
137
+ export function previousOccurrenceAtOrBefore(trigger, atMs, options = {}) {
138
+ const { hour, minute } = parseClock(trigger.time)
139
+ const start = localPartsAt(atMs, trigger.timezone)
140
+ for (let delta = 0; delta <= DATE_LIMIT; delta += 1) {
141
+ const date = addDays(start.date, -delta)
142
+ if (!matchesCadence(trigger, date)) continue
143
+ const verdict = calendarVerdict(trigger, date, options.calendar)
144
+ // The newest matching date decides: an unknown trading day fails closed instead of
145
+ // silently running the previous trading day's occurrence late.
146
+ if (verdict === undefined) return { kind: 'calendar_unavailable', localDate: date.date ?? date }
147
+ if (!verdict) continue
148
+ const instant = zonedWallToInstant(date, hour, minute, trigger.timezone)
149
+ if (instant === undefined || instant > atMs) continue
150
+ return occurrenceOf(trigger, instant, date)
151
+ }
152
+ return { kind: 'none' }
153
+ }
154
+
155
+ /** @returns same union as {@link previousOccurrenceAtOrBefore} */
156
+ export function nextOccurrence(trigger, afterMs, options = {}) {
157
+ const { hour, minute } = parseClock(trigger.time)
158
+ const start = localPartsAt(afterMs, trigger.timezone)
159
+ for (let delta = 0; delta <= DATE_LIMIT; delta += 1) {
160
+ const date = addDays(start.date, delta)
161
+ if (!matchesCadence(trigger, date)) continue
162
+ const verdict = calendarVerdict(trigger, date, options.calendar)
163
+ if (verdict === undefined) return { kind: 'calendar_unavailable', localDate: date.date ?? date }
164
+ if (!verdict) continue
165
+ const instant = zonedWallToInstant(date, hour, minute, trigger.timezone)
166
+ if (instant === undefined || instant <= afterMs) continue
167
+ return occurrenceOf(trigger, instant, date)
168
+ }
169
+ return { kind: 'none' }
170
+ }
171
+
172
+ function occurrenceOf(trigger, instant, localDate) {
173
+ return {
174
+ kind: 'occurrence',
175
+ instant,
176
+ scheduledFor: new Date(instant).toISOString(),
177
+ localDate: localDate.date ?? localDate,
178
+ scheduleVersion: scheduleVersionOf(trigger),
179
+ }
180
+ }
181
+
182
+ export function canonicalTrigger(trigger) {
183
+ return JSON.stringify({
184
+ kind: trigger.kind,
185
+ cadence: trigger.cadence,
186
+ time: trigger.time,
187
+ timezone: trigger.timezone,
188
+ weekdays: trigger.weekdays === undefined ? null : [...trigger.weekdays].sort((a, b) => a - b),
189
+ dayOfMonth: trigger.dayOfMonth ?? null,
190
+ tradingDaysOnly: trigger.tradingDaysOnly === true,
191
+ })
192
+ }
193
+
194
+ /** Stable across process restarts and object-key order; a trigger edit changes the version. */
195
+ export function scheduleVersionOf(trigger) {
196
+ return createHash('sha256').update(canonicalTrigger(trigger)).digest('hex').slice(0, 16)
197
+ }
198
+
199
+ export function occurrenceKeyOf(profileKey, taskId, scheduleVersion, scheduledFor) {
200
+ return `${profileKey}:${taskId}:${scheduleVersion}:${scheduledFor}`
201
+ }
202
+
203
+ export function manualOccurrenceKeyOf(profileKey, runId) {
204
+ return `${profileKey}:manual:${runId}`
205
+ }
206
+
207
+ /** Human display in the task zone; the stored value remains UTC. */
208
+ export function formatInZone(instantIso, timeZone) {
209
+ const instant = Date.parse(instantIso)
210
+ if (!Number.isFinite(instant)) return instantIso
211
+ return new Intl.DateTimeFormat('zh-CN', {
212
+ timeZone,
213
+ year: 'numeric',
214
+ month: '2-digit',
215
+ day: '2-digit',
216
+ hour: '2-digit',
217
+ minute: '2-digit',
218
+ hourCycle: 'h23',
219
+ }).format(new Date(instant))
220
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Workspace selection for automation runs. The task stores only a registered Workspace id;
3
+ * paths never enter the wire schema. The dedicated run directory is registered once so its
4
+ * sessions are grouped instead of landing in the ungrouped bucket.
5
+ */
6
+ import { mkdir, realpath } from 'node:fs/promises'
7
+
8
+ export class WorkspaceError extends Error {
9
+ constructor(code, message) {
10
+ super(message)
11
+ this.name = 'WorkspaceError'
12
+ this.code = code
13
+ }
14
+ }
15
+
16
+ const DEFAULT_REGISTRATIONS = new WeakMap()
17
+ const MAX_SESSIONS_TO_ADOPT = 500
18
+
19
+ function service(ctx, name) {
20
+ try {
21
+ return ctx.get(name)
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ export function createWorkspaceCatalog(options) {
28
+ const { ctx, displayName, runDir, logger } = options
29
+ let defaultEntity
30
+ let defaultPromise
31
+
32
+ function registry() {
33
+ return service(ctx, 'workspaceRegistry')
34
+ }
35
+
36
+ async function adoptSessions(entity) {
37
+ if (entity.sessionIds.length > 0) return
38
+ const persistence = service(ctx, 'sessionPersistence')
39
+ if (typeof persistence?.list !== 'function') return
40
+ let snapshots
41
+ try {
42
+ snapshots = await persistence.list()
43
+ } catch {
44
+ return
45
+ }
46
+ if (!Array.isArray(snapshots)) return
47
+ let canonical = entity.path
48
+ try {
49
+ canonical = await realpath(entity.path)
50
+ } catch {
51
+ // The registry already canonicalized the path; keep its spelling if realpath races away.
52
+ }
53
+ let adopted = 0
54
+ for (const snapshot of snapshots) {
55
+ if (adopted >= MAX_SESSIONS_TO_ADOPT) break
56
+ const header = snapshot?.header
57
+ if (header?.id === undefined || header.cwd === undefined) continue
58
+ if (header.cwd !== entity.path && header.cwd !== canonical && header.cwd !== runDir) continue
59
+ try {
60
+ await entity.attachSession(header.id)
61
+ adopted += 1
62
+ } catch {
63
+ // A stale header must not block the page or the next run.
64
+ }
65
+ }
66
+ }
67
+
68
+ async function ensureDefault() {
69
+ if (defaultEntity !== undefined) return defaultEntity
70
+ if (defaultPromise !== undefined) return defaultPromise
71
+ const current = registry()
72
+ if (current === undefined) return undefined
73
+ let registrations = DEFAULT_REGISTRATIONS.get(current)
74
+ if (registrations === undefined) {
75
+ registrations = new Map()
76
+ DEFAULT_REGISTRATIONS.set(current, registrations)
77
+ }
78
+ let sharedPromise = registrations.get(runDir)
79
+ if (sharedPromise === undefined) {
80
+ sharedPromise = (async () => {
81
+ await mkdir(runDir, { recursive: true, mode: 0o700 })
82
+ let created
83
+ if (typeof current.resolveByPath === 'function') {
84
+ created = await current.resolveByPath(runDir).catch(() => undefined)
85
+ }
86
+ if (created === undefined) {
87
+ try {
88
+ created = await current.create(runDir, `CapGen 投研自动化(${displayName})`)
89
+ } catch (error) {
90
+ const recovered = typeof current.resolveByPath === 'function'
91
+ ? await current.resolveByPath(runDir).catch(() => undefined)
92
+ : undefined
93
+ if (recovered === undefined) throw error
94
+ created = recovered
95
+ }
96
+ }
97
+ defaultEntity = created
98
+ await adoptSessions(created)
99
+ return created
100
+ })()
101
+ registrations.set(runDir, sharedPromise)
102
+ }
103
+ defaultPromise = sharedPromise
104
+ try {
105
+ return await sharedPromise
106
+ } catch (error) {
107
+ logger?.warn?.(`capital-automation: 无法注册默认工作区:${String(error)}`)
108
+ return undefined
109
+ } finally {
110
+ if (registrations.get(runDir) === sharedPromise) registrations.delete(runDir)
111
+ if (defaultEntity === undefined) defaultPromise = undefined
112
+ }
113
+ }
114
+
115
+ async function list() {
116
+ const current = registry()
117
+ if (current === undefined) return []
118
+ const preferred = await ensureDefault()
119
+ return current.list().map((entity) => ({
120
+ id: entity.id,
121
+ title: entity.title,
122
+ default: preferred !== undefined && entity.id === preferred.id,
123
+ }))
124
+ }
125
+
126
+ async function validate(id) {
127
+ if (typeof id !== 'string' || id.length === 0) return
128
+ const current = registry()
129
+ if (current === undefined) {
130
+ throw new WorkspaceError('workspace_unavailable', '工作区服务暂时不可用,不能指定工作区。')
131
+ }
132
+ const entity = current.get(id)
133
+ if (entity === undefined) {
134
+ throw new WorkspaceError('workspace_not_found', '选择的工作区不存在或已被移除。')
135
+ }
136
+ const status = await entity.status?.()
137
+ if (status !== undefined && status !== 'ok') {
138
+ throw new WorkspaceError('workspace_unavailable', `选择的工作区目录不可用:${entity.title}。`)
139
+ }
140
+ }
141
+
142
+ async function resolve(id) {
143
+ if (typeof id === 'string' && id.length > 0) {
144
+ await validate(id)
145
+ const entity = registry().get(id)
146
+ return { workspaceId: entity.id, path: entity.path }
147
+ }
148
+ const preferred = await ensureDefault()
149
+ if (preferred !== undefined) return { workspaceId: preferred.id, path: preferred.path }
150
+ return { workspaceId: undefined, path: runDir }
151
+ }
152
+
153
+ return { list, validate, resolve, runDir }
154
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-charts",
3
- "version": "2.11.1",
3
+ "version": "2.13.0",
4
4
  "description": "自动模式的图表前端组件(浏览器半边 + 序列旁路路由):由 @v587d/capital-generation 随包携带并作为 host 平面行挂载。",
5
5
  "license": "MIT",
6
6
  "type": "module",