@v587d/capital-generation 2.12.0 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +12 -12
- package/automation/builtins.js +147 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +68 -0
- package/automation/client.src.cjs +777 -0
- package/automation/defaults.js +71 -0
- package/automation/index.js +183 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +50 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +858 -0
- package/automation/scheduler.js +495 -0
- package/automation/schema.js +359 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +707 -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/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 +4 -2
- package/watchlist/package.json +1 -1
|
@@ -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
|
+
}
|