@v587d/capital-generation 0.0.0-stage → 2.6.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 (117) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -2
  4. package/assets/cg-icon.png +0 -0
  5. package/capital-config/client.js +28 -0
  6. package/capital-config/client.src.cjs +388 -0
  7. package/capital-config/index.js +113 -0
  8. package/capital-config/package.json +36 -0
  9. package/capital-watchlist/client.js +182 -0
  10. package/capital-watchlist/client.src.cjs +1229 -0
  11. package/capital-watchlist/index.js +772 -0
  12. package/capital-watchlist/package.json +37 -0
  13. package/chart-ui/README.md +112 -0
  14. package/chart-ui/client.js +16 -0
  15. package/chart-ui/client.src.cjs +126 -0
  16. package/chart-ui/index.js +205 -0
  17. package/chart-ui/package.json +30 -0
  18. package/cordis.patch.yml +72 -0
  19. package/lib/agents/bash-guard.d.ts +55 -0
  20. package/lib/agents/bash-guard.js +162 -0
  21. package/lib/agents/root-tool-policy.d.ts +164 -0
  22. package/lib/agents/root-tool-policy.js +309 -0
  23. package/lib/chart/artifact-ref.d.ts +65 -0
  24. package/lib/chart/artifact-ref.js +107 -0
  25. package/lib/chart/downsample.d.ts +8 -0
  26. package/lib/chart/downsample.js +53 -0
  27. package/lib/chart/errors.d.ts +16 -0
  28. package/lib/chart/errors.js +15 -0
  29. package/lib/chart/events.d.ts +120 -0
  30. package/lib/chart/events.js +471 -0
  31. package/lib/chart/html.d.ts +21 -0
  32. package/lib/chart/html.js +154 -0
  33. package/lib/chart/runtime.d.ts +20 -0
  34. package/lib/chart/runtime.js +270 -0
  35. package/lib/chart/series.d.ts +102 -0
  36. package/lib/chart/series.js +424 -0
  37. package/lib/chart/source-token.d.ts +64 -0
  38. package/lib/chart/source-token.js +217 -0
  39. package/lib/chart/spec.d.ts +58 -0
  40. package/lib/chart/spec.js +265 -0
  41. package/lib/chart/tool.d.ts +86 -0
  42. package/lib/chart/tool.js +288 -0
  43. package/lib/chart/vendor/LICENSE +201 -0
  44. package/lib/chart/vendor/VERSION.json +8 -0
  45. package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
  46. package/lib/data-collector/args.d.ts +38 -0
  47. package/lib/data-collector/args.js +114 -0
  48. package/lib/data-collector/dataset-tools.d.ts +3 -0
  49. package/lib/data-collector/dataset-tools.js +421 -0
  50. package/lib/data-collector/describe.d.ts +99 -0
  51. package/lib/data-collector/describe.js +256 -0
  52. package/lib/data-collector/hub.d.ts +187 -0
  53. package/lib/data-collector/hub.js +469 -0
  54. package/lib/data-collector/query-time.d.ts +58 -0
  55. package/lib/data-collector/query-time.js +101 -0
  56. package/lib/data-collector/query.d.ts +57 -0
  57. package/lib/data-collector/query.js +473 -0
  58. package/lib/data-collector/store.d.ts +499 -0
  59. package/lib/data-collector/store.js +1486 -0
  60. package/lib/data-collector/time-axis.d.ts +169 -0
  61. package/lib/data-collector/time-axis.js +419 -0
  62. package/lib/data-collector/tools.d.ts +32 -0
  63. package/lib/data-collector/tools.js +216 -0
  64. package/lib/index.d.ts +275 -0
  65. package/lib/index.js +458 -0
  66. package/lib/net/eastmoney-client.d.ts +104 -0
  67. package/lib/net/eastmoney-client.js +118 -0
  68. package/lib/net/throttle.d.ts +16 -0
  69. package/lib/net/throttle.js +34 -0
  70. package/lib/ocr/client.d.ts +82 -0
  71. package/lib/ocr/client.js +281 -0
  72. package/lib/ocr/markdown.d.ts +75 -0
  73. package/lib/ocr/markdown.js +209 -0
  74. package/lib/ocr/store.d.ts +119 -0
  75. package/lib/ocr/store.js +226 -0
  76. package/lib/sources/eastmoney-http.d.ts +2 -0
  77. package/lib/sources/eastmoney-http.js +1253 -0
  78. package/lib/sources/fuyao-core.d.ts +52 -0
  79. package/lib/sources/fuyao-core.js +96 -0
  80. package/lib/sources/fuyao-rest.d.ts +14 -0
  81. package/lib/sources/fuyao-rest.js +2057 -0
  82. package/lib/sources/security-code.d.ts +27 -0
  83. package/lib/sources/security-code.js +104 -0
  84. package/lib/sources/security-identity.d.ts +24 -0
  85. package/lib/sources/security-identity.js +89 -0
  86. package/lib/sources/tencent-http.d.ts +2 -0
  87. package/lib/sources/tencent-http.js +1053 -0
  88. package/lib/sources/wind-mcp.d.ts +8 -0
  89. package/lib/sources/wind-mcp.js +593 -0
  90. package/lib/time/tools.d.ts +74 -0
  91. package/lib/time/tools.js +464 -0
  92. package/lib/tool-exec.d.ts +29 -0
  93. package/lib/tool-exec.js +17 -0
  94. package/lib/web-retriever/engines.d.ts +61 -0
  95. package/lib/web-retriever/engines.js +202 -0
  96. package/lib/web-retriever/html-markdown.d.ts +50 -0
  97. package/lib/web-retriever/html-markdown.js +321 -0
  98. package/lib/web-retriever/local-fetch.d.ts +121 -0
  99. package/lib/web-retriever/local-fetch.js +562 -0
  100. package/lib/web-retriever/retriever.d.ts +58 -0
  101. package/lib/web-retriever/retriever.js +94 -0
  102. package/lib/web-retriever/sources.d.ts +235 -0
  103. package/lib/web-retriever/sources.js +961 -0
  104. package/lib/web-retriever/tools.d.ts +50 -0
  105. package/lib/web-retriever/tools.js +1029 -0
  106. package/lib/web-retriever/wind-client.d.ts +69 -0
  107. package/lib/web-retriever/wind-client.js +311 -0
  108. package/locale/en.json +6 -0
  109. package/locale/zh.json +6 -0
  110. package/package.json +76 -4
  111. package/preset/capital-generation/README.md +161 -0
  112. package/preset/capital-generation/agent.patch.yml +509 -0
  113. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
  114. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
  115. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
  116. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
  117. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
@@ -0,0 +1,169 @@
1
+ import type { ProfileTimeAxis } from './store.js';
2
+ export type AxisValueFormat = 'epoch_ms' | 'date_string' | 'unknown';
3
+ /** epoch 毫秒 → 该时区下的 `YYYY-MM-DD`;时区非法时退化为 UTC,绝不抛错。 */
4
+ export declare function isoDateFromEpoch(epochMs: number, zone: string): string;
5
+ /**
6
+ * 某时区下"该日 00:00 的 epoch"。
7
+ *
8
+ * 两遍收敛:先用目标日 UTC 午夜的偏移作估计,再按估计时刻的偏移校正一次——
9
+ * 对固定偏移时区(A 股数据里全部如此)一遍即准,对 DST 边界也只在跨日时刻才有差别。
10
+ */
11
+ export declare function zoneDayStartMs(date: string, zone: string): number;
12
+ /** 某时区下"该日 23:59:59.999 的 epoch",与 {@link zoneDayStartMs} 同一天。 */
13
+ export declare function zoneDayEndMs(date: string, zone: string): number;
14
+ /** 把毫秒偏移写成 `+08:00` / `-03:30` / `+00:00`。 */
15
+ export declare function formatOffset(offsetMs: number): string;
16
+ /**
17
+ * 从列值推断时间轴的形态、偏移与对齐方式。
18
+ *
19
+ * - 全部是数字且都是 86_400_000 的整数倍 → 日粒度 epoch;偏移 = 值 mod 一天;
20
+ * - 全部是 `YYYY-MM-DD` 样式字符串 → date_string(ISO 写法,模型可读,无需换算);
21
+ * - 其余(秒级 epoch、混合类型、带时间的字符串)→ unknown,**不做猜测**:
22
+ * 宿主宁可让模型看到 `unknown` 并用工具确认,也不要给一个看似合理的错误边界。
23
+ */
24
+ export declare function probeTimeAxis(column: string, values: unknown[]): ProfileTimeAxis;
25
+ /** 时间轴上的一次边界换算:日历 → 该列里真正可用的值 / 查询下界。 */
26
+ export interface AxisBounds {
27
+ /** 日历起点(含):`>= value_ge` 即"从这一天开始"。 */
28
+ value_ge: number | string;
29
+ /** 日历终点(含)的日末:`<= value_le` 即"到这一天结束"。 */
30
+ value_le: number | string;
31
+ /** 该窗口内 Dataset 首行/末行的日期(无数据时为 null)。 */
32
+ data_from: string | null;
33
+ data_to: string | null;
34
+ }
35
+ /**
36
+ * 把一条 filter 的日期写法翻译成该时间轴上可比较的值。
37
+ *
38
+ * 三种结果:
39
+ * - `filters`:翻译后的条件(`=` 展开成 `>= value_ge` + `<= value_le` 两条),交给引擎;
40
+ * - `undefined`:这条不需要翻译(值不是日期 / 列不是时间列);
41
+ * - 抛错:**写法在此列上无意义**(日期字符串落到数值时间列等)。绝不静默放行——
42
+ * 旧行为是 `skip_with_warning` 把该行整条跳过,等于"筛选条件消失、返回全量",
43
+ * 而模型只会看到一份看起来成功的全量结果(比报错危险得多)。
44
+ */
45
+ export declare function translateFilterDateValue(input: {
46
+ axis: ProfileTimeAxis;
47
+ /** 探针看到的日期(升序),period 窗口要用它夹到真实存在的交易日。 */
48
+ dates: string[];
49
+ column: string;
50
+ operator: string;
51
+ value: unknown;
52
+ }): {
53
+ operator: string;
54
+ value: unknown;
55
+ }[] | undefined;
56
+ export interface AxisWindow extends AxisBounds {
57
+ /** 窗口名,例如 `last_3_months`、`ytd`、`2026-10`。 */
58
+ name: string;
59
+ /** 解析出的日历区间(半开:start ≤ 日期 ≤ end)。 */
60
+ start_date: string;
61
+ end_date: string;
62
+ }
63
+ /**
64
+ * Dataset 模式的时间窗解析结果:把"近 3 个月"这类说法直接翻译成**可直接使用的查询边界**,
65
+ * 并同时给出人类可读的日期与 Dataset 里真实存在的首末日期。
66
+ *
67
+ * 这是 data_junior 侧 `resolve_data_time_range` 的第二形态(第一形态仍是"给 request_data
68
+ * 生成上游时间参数")。两形态共用同一套 period 解析(`time/tools.ts` 的 resolveZonedPeriod),
69
+ * 避免同一个词在两侧得到不同答案。
70
+ */
71
+ export interface DatasetTimeResolution {
72
+ mode: 'dataset';
73
+ format: 'epoch_ms' | 'date_string';
74
+ timezone: string;
75
+ time_column: string;
76
+ period: string;
77
+ /** 解析出的日历区间(名义区间)。 */
78
+ range: {
79
+ start_date: string;
80
+ end_date: string;
81
+ };
82
+ /** 该区间内**真实存在**的首末日期(非交易日自动夹到相邻交易日)。 */
83
+ data: {
84
+ from: string | null;
85
+ to: string | null;
86
+ days: number | null;
87
+ };
88
+ /** 可直接使用的边界:`{column: time_column, operator: '>=', value: bound_ge}`。 */
89
+ bounds: {
90
+ bound_ge: number | string;
91
+ bound_le: number | string;
92
+ inclusive: boolean;
93
+ };
94
+ bounds_are_intraday: boolean;
95
+ /** Dataset 的完整覆盖范围(ISO 形态)。 */
96
+ dataset: {
97
+ covered_from: string;
98
+ covered_to: string;
99
+ };
100
+ /** 名义区间与真实数据不一致等必须让模型知道的事实(协议要求"warnings 必须遵守")。 */
101
+ warnings: string[];
102
+ }
103
+ /** period 的两种写法:字面窗口(`last_3_months`)或 `{unit,count}`。 */
104
+ export type PeriodInput = string | {
105
+ unit: string;
106
+ count: number;
107
+ };
108
+ /** period 的可读名字:对象形态写成 `2_year`,省略或空串写成 `all`。 */
109
+ export declare function periodName(period: PeriodInput | undefined): string;
110
+ export declare function resolveDatasetTimeWindow(input: {
111
+ dataset_id: string;
112
+ axis: ProfileTimeAxis;
113
+ dates: string[];
114
+ period?: PeriodInput;
115
+ anchorDate?: string;
116
+ zone?: string;
117
+ }): DatasetTimeResolution;
118
+ /**
119
+ * 把「日历窗口」解析成该时间轴上可用的边界值。
120
+ *
121
+ * `data_from` / `data_to` 用**该列自己的粒度**把窗口夹到真实存在的日期上:
122
+ * 窗口起点落在周末/节假日时前移到下一个有数据的日期,终点回退到最后一个有数据的日期。
123
+ * 这样"近 1 个月"这类窗口不会因为非交易日而给出空结果。
124
+ */
125
+ /**
126
+ * 把「日历窗口」解析成该时间轴上可用的边界值。
127
+ *
128
+ * `data_from` / `data_to` 用**该列自己的粒度**把窗口夹到真实存在的日期上:窗口首日落在
129
+ * 周末/节假日时前移到下一个有数据的日期,末日回退到最后一个有数据的日期。
130
+ *
131
+ * 锚点本身是非交易日时(例如"以周日为锚的近 1 周")末日**再往后认一天**:
132
+ * 名义窗口 [9/14, 9/20] 里最后一个交易日是 9/18,而锚点想表达的"最近一周"其实该到 9/21。
133
+ * 这不是猜测:只有当紧邻末日的下一个日期确实存在、且它晚于锚点时才会这样延伸。
134
+ */
135
+ export declare function resolveAxisWindow(input: {
136
+ axis: ProfileTimeAxis;
137
+ dates: string[];
138
+ period?: PeriodInput;
139
+ anchorDate?: string;
140
+ zone?: string;
141
+ }): AxisWindow;
142
+ /**
143
+ * 单值 → 该列里代表"这一天"的取值。
144
+ *
145
+ * `epoch_ms` 轴取当地午夜(value_ge 与 value_le 相等时的那个值),`date_string` 轴原样返回。
146
+ * 用于 filter 的 `=` / `in`:先把日期压成单值,再交给引擎做相等比较。
147
+ */
148
+ export declare function axisValueForDate(axis: ProfileTimeAxis, date: string): number | string;
149
+ /**
150
+ * 读一份 Dataset 的时间轴(形态 + 已出现的日期清单)。
151
+ *
152
+ * 日期清单只用于把窗口夹到真实存在的交易日,所以按升序逐行收集、遇到更早的值才插入,
153
+ * 到上限即停:`date_ms` 数据是有序的,正常情况下一次遍历就装满。
154
+ */
155
+ export declare const MAX_AXIS_DATES = 20000;
156
+ export declare function readAxisDates(input: {
157
+ axis: ProfileTimeAxis;
158
+ rows: unknown[];
159
+ limit?: number;
160
+ }): string[];
161
+ /** profile 的 `time_facts.windows`:最常见的几个窗口,让模型不必自己算锚点。
162
+ *
163
+ * 只给"数据里确实存在"的窗口;空窗口不出现,避免模型拿一个空区间去查询再困惑。
164
+ */
165
+ export declare const DEFAULT_WINDOW_PERIODS: readonly ["last_1_month", "last_3_months", "last_1_year", "ytd"];
166
+ export declare function defaultAxisWindows(input: {
167
+ axis: ProfileTimeAxis;
168
+ dates: string[];
169
+ }): AxisWindow[];
@@ -0,0 +1,419 @@
1
+ import { DatasetQueryError } from './query.js';
2
+ import { dateParts, periodInputOf, resolveZonedPeriod, zoneOffsetMsByDiff } from '../time/tools.js';
3
+ /**
4
+ * Dataset 时间轴:把「日历」与「列里的时间值」双向对齐。
5
+ *
6
+ * 为什么必须由宿主做(2026-09-23 真机会话实测,问题单 session 1e44050e):
7
+ * data_junior 拿到 `time_facts.covered_from = 1695571200000` 之后,要判断覆盖到哪一天、
8
+ * 能不能做 52 周窗口、月末锚点是多少,只能自己把 epoch 换算成日历;而 `query_dataset` 的
9
+ * filter 值又只接受毫秒,于是"2026-08-23 是多少毫秒"这类问题被反复心算。
10
+ * 结果是单步思考 41,961 字符里绝大部分是时间戳算术,且口径随时可能算错(它自己在思考里
11
+ * 写了 "Risky")。时间换算是**确定性计算**,属于宿主:
12
+ *
13
+ * 1. 正向(值 → 日历):{@link isoFromAxisValue} 供 profile 的 time_facts 输出 ISO;
14
+ * 2. 反向(日历 → 值):{@link resolveAxisWindow} 把 ISO 日期/相对期解析成该列自己的边界值;
15
+ * 3. 语义:{@link probeTimeAxis} 从**列自身观测到的偏移**推断时区,绝不猜。
16
+ *
17
+ * 日线数据里 `date_ms` = 当地午夜 00:00 的 epoch(实测 1695571200000 = 2023-09-25 00:00 +08:00
18
+ * = UTC 前一天 16:00),所以"当天"的边界必须按该列的偏移算:值同时满足
19
+ * `>= dayStart` 与 `<= dayEnd` 才算落在这一天。
20
+ */
21
+ /** 一天的毫秒数。 */
22
+ const DAY_MS = 86_400_000;
23
+ /**
24
+ * 从"当地午夜的 epoch"反解时区偏移。
25
+ *
26
+ * 列里的值就是某地 00:00 的瞬间,所以它对一天的余数恰好是"UTC 当天几点",
27
+ * 时区偏移 = 该余数的**相反数**:A 股数据余数 57_600_000(UTC 16:00)⇒ +08:00。
28
+ * 余数先归一到 `(-12h, +12h]`(超半天即换到前一天),否则 +08:00 会被读成 -16:00。
29
+ */
30
+ function zoneOffsetFromMidnightValue(value) {
31
+ const remainder = value % DAY_MS;
32
+ const utcHourOfDay = remainder > DAY_MS / 2 ? remainder - DAY_MS : remainder;
33
+ return -utcHourOfDay;
34
+ }
35
+ /** 推断出的偏移必须落在真实时区范围内(UTC-12:00 ~ UTC+14:00)。 */
36
+ const MIN_OFFSET_MS = -12 * 3_600_000;
37
+ const MAX_OFFSET_MS = 14 * 3_600_000;
38
+ /**
39
+ * 把数值列当 epoch 毫秒的必要量级。秒级 epoch(10 位,约 1.7e9)与毫秒级(13 位,约 1.8e12)
40
+ * 差三个数量级:不加这道闸门时,秒级时间戳会被误判成"日粒度 epoch"(1.7e9 恰好是 86400 的
41
+ * 整数倍),于是所有窗口边界整体错到 1970 年附近。1e11 ms ≈ 1973 年,足以把两者分开。
42
+ */
43
+ const MIN_EPOCH_MS = 100_000_000_000;
44
+ /** 能一键还原成 IANA 名的常见股票/基金数据时区(只有偏移等于它时才用名字,避免误标)。 */
45
+ const CANONICAL_ZONES = [
46
+ { zone: 'Asia/Shanghai', offsetMs: 8 * 3_600_000 },
47
+ { zone: 'UTC', offsetMs: 0 },
48
+ ];
49
+ function isRecordLike(value) {
50
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
51
+ }
52
+ /** 行内取列值:与 store 的 profileCell 同义,只为不把 store 的私有实现引到这里。 */
53
+ function cellOf(row, column) {
54
+ if (!isRecordLike(row))
55
+ return { present: false, value: undefined };
56
+ return Object.prototype.hasOwnProperty.call(row, column)
57
+ ? { present: true, value: row[column] }
58
+ : { present: false, value: undefined };
59
+ }
60
+ /** 某时刻在某时区下的日历年月日;时区非法或 Intl 不可用时返回 undefined。 */
61
+ function readDateParts(epochMs, zone) {
62
+ try {
63
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' })
64
+ .formatToParts(epochMs);
65
+ const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
66
+ return { year: Number(map.year), month: Number(map.month), day: Number(map.day) };
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /** `YYYY-MM-DD` 的严格解析:非法日期在这里就响亮失败,不带进任何换算。 */
73
+ function readCalendarDate(value) {
74
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
75
+ if (!match)
76
+ throw new Error(`date must use YYYY-MM-DD: ${JSON.stringify(value)}`);
77
+ const parts = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
78
+ const check = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
79
+ if (check.getUTCFullYear() !== parts.year || check.getUTCMonth() + 1 !== parts.month || check.getUTCDate() !== parts.day) {
80
+ throw new Error(`invalid calendar date: ${value}`);
81
+ }
82
+ return parts;
83
+ }
84
+ /** 日历天数推进:只按日/周/月/季度算,调用方只用到这三种。 */
85
+ function shiftCalendarDate(value, unit, amount) {
86
+ const parts = dateParts(value);
87
+ if (unit === 'day') {
88
+ const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
89
+ date.setUTCDate(date.getUTCDate() + amount);
90
+ return `${String(date.getUTCFullYear()).padStart(4, '0')}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
91
+ }
92
+ const totalMonths = parts.year * 12 + parts.month - 1 + amount * (unit === 'quarter' ? 3 : 1);
93
+ const year = Math.floor(totalMonths / 12);
94
+ const month = totalMonths % 12 + 1;
95
+ const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
96
+ return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(Math.min(parts.day, lastDay)).padStart(2, '0')}`;
97
+ }
98
+ function finiteNumbers(values) {
99
+ return values.filter((value) => typeof value === 'number' && Number.isFinite(value));
100
+ }
101
+ /** epoch 毫秒 → 该时区下的 `YYYY-MM-DD`;时区非法时退化为 UTC,绝不抛错。 */
102
+ export function isoDateFromEpoch(epochMs, zone) {
103
+ const parts = readDateParts(epochMs, zone) ?? readDateParts(epochMs, 'UTC');
104
+ if (parts === undefined)
105
+ return new Date(epochMs).toISOString().slice(0, 10);
106
+ return `${String(parts.year).padStart(4, '0')}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
107
+ }
108
+ /**
109
+ * 某时区下"该日 00:00 的 epoch"。
110
+ *
111
+ * 两遍收敛:先用目标日 UTC 午夜的偏移作估计,再按估计时刻的偏移校正一次——
112
+ * 对固定偏移时区(A 股数据里全部如此)一遍即准,对 DST 边界也只在跨日时刻才有差别。
113
+ */
114
+ export function zoneDayStartMs(date, zone) {
115
+ const parts = readCalendarDate(date);
116
+ const utcMidnight = Date.UTC(parts.year, parts.month - 1, parts.day);
117
+ const estimated = utcMidnight - zoneOffsetMsByDiff(utcMidnight, zone);
118
+ return utcMidnight - zoneOffsetMsByDiff(estimated, zone);
119
+ }
120
+ /** 某时区下"该日 23:59:59.999 的 epoch",与 {@link zoneDayStartMs} 同一天。 */
121
+ export function zoneDayEndMs(date, zone) {
122
+ return zoneDayStartMs(shiftCalendarDate(date, 'day', 1), zone) - 1;
123
+ }
124
+ /** 把毫秒偏移写成 `+08:00` / `-03:30` / `+00:00`。 */
125
+ export function formatOffset(offsetMs) {
126
+ const sign = offsetMs < 0 ? '-' : '+';
127
+ const abs = Math.abs(offsetMs);
128
+ const hours = Math.floor(abs / 3_600_000);
129
+ const minutes = Math.floor((abs % 3_600_000) / 60_000);
130
+ return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
131
+ }
132
+ /** 偏移落到常见数据时区时返回 IANA 名,否则返回 `UTC+08:00` 形态的说明串。 */
133
+ function zoneForOffset(offsetMs) {
134
+ const canonical = CANONICAL_ZONES.find((entry) => entry.offsetMs === offsetMs);
135
+ return canonical ? canonical.zone : `UTC${formatOffset(offsetMs)}`;
136
+ }
137
+ /**
138
+ * 从列值推断时间轴的形态、偏移与对齐方式。
139
+ *
140
+ * - 全部是数字且都是 86_400_000 的整数倍 → 日粒度 epoch;偏移 = 值 mod 一天;
141
+ * - 全部是 `YYYY-MM-DD` 样式字符串 → date_string(ISO 写法,模型可读,无需换算);
142
+ * - 其余(秒级 epoch、混合类型、带时间的字符串)→ unknown,**不做猜测**:
143
+ * 宿主宁可让模型看到 `unknown` 并用工具确认,也不要给一个看似合理的错误边界。
144
+ */
145
+ export function probeTimeAxis(column, values) {
146
+ const present = values.filter((value) => value !== null && value !== undefined);
147
+ if (present.length === 0)
148
+ return { column, value_format: 'unknown' };
149
+ const numbers = finiteNumbers(present);
150
+ if (numbers.length === present.length) {
151
+ // 日粒度 = 每个值都等于"某天 + 同一个日内偏移",且这个偏移落在合法时区范围内。
152
+ // 只检查 `value % DAY_MS === 0` 是不够的:A 股数据的当地午夜等于 UTC 前一天 16:00,
153
+ // 余数是 57_600_000 而不是 0——按那个判据会把整列判成 unknown(实测踩过)。
154
+ const offsetMs = zoneOffsetFromMidnightValue(numbers[0]);
155
+ const dayAligned = numbers.every((value) => Number.isSafeInteger(value)
156
+ && value >= MIN_EPOCH_MS
157
+ && zoneOffsetFromMidnightValue(value) === offsetMs);
158
+ const usable = offsetMs >= MIN_OFFSET_MS && offsetMs <= MAX_OFFSET_MS;
159
+ return {
160
+ column,
161
+ value_format: 'epoch_ms',
162
+ ...(dayAligned && usable
163
+ ? { time_zone: zoneForOffset(offsetMs), utc_offset: formatOffset(offsetMs), aligned_to: 'local_midnight' }
164
+ : { aligned_to: 'unknown' }),
165
+ };
166
+ }
167
+ const strings = present.filter((value) => typeof value === 'string');
168
+ const allDateLike = strings.length === present.length && strings.every((value) => /^\d{4}-\d{2}-\d{2}$/.test(value.trim()));
169
+ if (allDateLike)
170
+ return { column, value_format: 'date_string', aligned_to: 'calendar_day' };
171
+ return { column, value_format: 'unknown' };
172
+ }
173
+ /**
174
+ * 把一条 filter 的日期写法翻译成该时间轴上可比较的值。
175
+ *
176
+ * 三种结果:
177
+ * - `filters`:翻译后的条件(`=` 展开成 `>= value_ge` + `<= value_le` 两条),交给引擎;
178
+ * - `undefined`:这条不需要翻译(值不是日期 / 列不是时间列);
179
+ * - 抛错:**写法在此列上无意义**(日期字符串落到数值时间列等)。绝不静默放行——
180
+ * 旧行为是 `skip_with_warning` 把该行整条跳过,等于"筛选条件消失、返回全量",
181
+ * 而模型只会看到一份看起来成功的全量结果(比报错危险得多)。
182
+ */
183
+ export function translateFilterDateValue(input) {
184
+ const { axis, dates, column, operator, value } = input;
185
+ if (column !== axis.column || axis.value_format === 'unknown')
186
+ return undefined;
187
+ const asDate = typeof value === 'string' ? dateFromText(value) : undefined;
188
+ const asPeriod = isRecordLike(value) ? periodInputOf(value.period) : undefined;
189
+ // `in` 的数组形态:逐个识别,一个日期都不认识才算"不是日期写法"。
190
+ const inDates = operator === 'in' && Array.isArray(value)
191
+ ? value.filter((item) => typeof item === 'string' && dateFromText(item) !== undefined)
192
+ : [];
193
+ if (asDate === undefined && asPeriod === undefined && inDates.length === 0)
194
+ return undefined;
195
+ const label = asDate ?? (asPeriod === undefined ? inDates.join(',') : `${asPeriod}(period)`);
196
+ const range = /^(>=|<=|=|>|<)$/.exec(operator);
197
+ if (range === null) {
198
+ // `in` / `!=` 在"整天"语义下表达的都是"排除"或"多天集合":前者要求把每个日期
199
+ // 展开成两条边界(会被 filter 上限打回),后者根本不是范围查询。
200
+ // 与其给一个差一天的答案,不如让模型改用 >= / <= 明确写出要哪些天。
201
+ throw new DatasetQueryError('query_type_conflict', `filter ${column} cannot take a date-like value with operator ${operator}; dates always mean whole days, so write the range you want with >= and <= (got ${label})`);
202
+ }
203
+ // 日期只有"整天"的语义:> 与 < 会把边界挪到前一天/后一天的 23:59:59.999,
204
+ // 等于悄悄换一个窗口——宁可响亮失败,也不给一个差一天的答案。
205
+ if (operator === '>' || operator === '<') {
206
+ throw new DatasetQueryError('query_type_conflict', `filter ${column} cannot take a date-like value with operator ${operator}; a date means the whole day, so use >= (or <=) instead of comparison ${operator} (for a window, pass YYYY-MM-DD or { period })`);
207
+ }
208
+ const resolve = (date) => (axis.value_format === 'date_string'
209
+ ? { start: date, end: date }
210
+ : { start: zoneDayStartMs(date, axis.time_zone ?? 'UTC'), end: zoneDayEndMs(date, axis.time_zone ?? 'UTC') });
211
+ const bounds = asDate !== undefined
212
+ ? resolve(asDate)
213
+ : (() => {
214
+ const window = resolveAxisWindow({ axis, dates, period: asPeriod });
215
+ return { start: window.value_ge, end: window.value_le };
216
+ })();
217
+ switch (operator) {
218
+ case '>=':
219
+ return [{ operator: '>=', value: bounds.start }];
220
+ case '<=':
221
+ return [{ operator: '<=', value: bounds.end }];
222
+ case '=':
223
+ return [{ operator: '>=', value: bounds.start }, { operator: '<=', value: bounds.end }];
224
+ default:
225
+ throw new DatasetQueryError('query_type_conflict', `filter ${column} cannot exclude a whole day by ${label} with ${operator}; ask for the days you want with >= / <= instead`);
226
+ }
227
+ }
228
+ /** `YYYY-MM-DD` / `YYYY-MM` / `YYYY` → 该区间的首日;其余文本返回 undefined。 */
229
+ function dateFromText(value) {
230
+ const text = value.trim();
231
+ const full = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
232
+ if (full) {
233
+ try {
234
+ readCalendarDate(text);
235
+ return text;
236
+ }
237
+ catch {
238
+ return undefined;
239
+ }
240
+ }
241
+ const month = /^(\d{4})-(\d{2})$/.exec(text);
242
+ if (month) {
243
+ const index = Number(month[2]);
244
+ return index >= 1 && index <= 12 ? `${month[1]}-${month[2]}-01` : undefined;
245
+ }
246
+ const year = /^(\d{4})$/.exec(text);
247
+ return year ? `${year[1]}-01-01` : undefined;
248
+ }
249
+ /** period 的可读名字:对象形态写成 `2_year`,省略或空串写成 `all`。 */
250
+ export function periodName(period) {
251
+ if (period === undefined)
252
+ return 'all';
253
+ if (typeof period === 'string')
254
+ return period.trim() === '' ? 'all' : period.trim();
255
+ return `${period.count}_${period.unit}`;
256
+ }
257
+ export function resolveDatasetTimeWindow(input) {
258
+ const { axis, dates } = input;
259
+ if (axis.value_format === 'unknown') {
260
+ throw new Error(`Dataset ${input.dataset_id} has no usable time axis: ${axis.column} values are neither epoch-day milliseconds nor YYYY-MM-DD strings; inspect the column before composing time filters`);
261
+ }
262
+ const sorted = [...new Set(dates)].sort();
263
+ const window = resolveAxisWindow({ axis, dates: sorted, period: input.period, anchorDate: input.anchorDate, zone: input.zone });
264
+ const from = window.data_from;
265
+ const to = window.data_to;
266
+ const warnings = [];
267
+ if (from !== null && from !== window.start_date)
268
+ warnings.push(`窗口起点 ${window.start_date} 在数据里没有对应行(非交易日或超出覆盖范围),实际首日 ${from}`);
269
+ if (to !== null && to !== window.end_date)
270
+ warnings.push(`窗口终点 ${window.end_date} 在数据里没有对应行,实际末日 ${to}`);
271
+ return {
272
+ mode: 'dataset',
273
+ format: axis.value_format,
274
+ timezone: input.zone ?? axis.time_zone ?? 'UTC',
275
+ time_column: axis.column,
276
+ period: window.name,
277
+ range: { start_date: window.start_date, end_date: window.end_date },
278
+ data: {
279
+ from,
280
+ to,
281
+ days: from === null || to === null ? null : sorted.filter((date) => date >= from && date <= to).length,
282
+ },
283
+ bounds: { bound_ge: window.value_ge, bound_le: window.value_le, inclusive: true },
284
+ bounds_are_intraday: axis.value_format === 'epoch_ms',
285
+ dataset: { covered_from: sorted[0], covered_to: sorted[sorted.length - 1] },
286
+ warnings,
287
+ };
288
+ }
289
+ function boundsFor(axis, startDate, endDate) {
290
+ if (axis.value_format === 'date_string') {
291
+ return { value_ge: startDate, value_le: endDate, data_from: startDate, data_to: endDate };
292
+ }
293
+ const zone = axis.time_zone ?? 'UTC';
294
+ return {
295
+ value_ge: zoneDayStartMs(startDate, zone),
296
+ value_le: zoneDayEndMs(endDate, zone),
297
+ data_from: startDate,
298
+ data_to: endDate,
299
+ };
300
+ }
301
+ /**
302
+ * 把「日历窗口」解析成该时间轴上可用的边界值。
303
+ *
304
+ * `data_from` / `data_to` 用**该列自己的粒度**把窗口夹到真实存在的日期上:
305
+ * 窗口起点落在周末/节假日时前移到下一个有数据的日期,终点回退到最后一个有数据的日期。
306
+ * 这样"近 1 个月"这类窗口不会因为非交易日而给出空结果。
307
+ */
308
+ /**
309
+ * 把「日历窗口」解析成该时间轴上可用的边界值。
310
+ *
311
+ * `data_from` / `data_to` 用**该列自己的粒度**把窗口夹到真实存在的日期上:窗口首日落在
312
+ * 周末/节假日时前移到下一个有数据的日期,末日回退到最后一个有数据的日期。
313
+ *
314
+ * 锚点本身是非交易日时(例如"以周日为锚的近 1 周")末日**再往后认一天**:
315
+ * 名义窗口 [9/14, 9/20] 里最后一个交易日是 9/18,而锚点想表达的"最近一周"其实该到 9/21。
316
+ * 这不是猜测:只有当紧邻末日的下一个日期确实存在、且它晚于锚点时才会这样延伸。
317
+ */
318
+ export function resolveAxisWindow(input) {
319
+ const { axis, dates } = input;
320
+ const zone = input.zone ?? axis.time_zone ?? 'UTC';
321
+ const sorted = [...new Set(dates)].sort();
322
+ if (sorted.length === 0)
323
+ throw new Error('dataset has no usable time values on the time axis');
324
+ const anchor = input.anchorDate ?? sorted[sorted.length - 1];
325
+ // 锚点就是 Dataset 最后一天时,"到锚点为止"不该再往后认一天(那是把窗口拖到未来);
326
+ // 只有当锚点明确超出现有数据、且窗口末端被截断时,末日的下一个交易日才属于该窗口。
327
+ const anchorIsDatasetEnd = anchor === sorted[sorted.length - 1];
328
+ // 名字只用于展示:对象形态的 period(`{unit,count}`)必须**原样**交给解析器,
329
+ // 不能先转成展示名再解析(`2_year` 不是合法 period,会掉进 unsupported period)。
330
+ const period = periodName(input.period);
331
+ const resolved = resolveZonedPeriod(input.period ?? 'all', anchor);
332
+ const clipped = clampToDates(sorted, resolved.start, resolved.end, { anchor, extend: !anchorIsDatasetEnd });
333
+ if (clipped === undefined) {
334
+ throw new Error(`period ${period} covers no rows in this Dataset (dataset covers ${sorted[0]} ~ ${sorted[sorted.length - 1]})`);
335
+ }
336
+ return {
337
+ name: period,
338
+ start_date: resolved.start,
339
+ end_date: resolved.end,
340
+ ...boundsFor(axis, resolved.start, resolved.end),
341
+ data_from: clipped.from,
342
+ data_to: clipped.to,
343
+ };
344
+ }
345
+ /** 把窗口夹到真实存在的日期:起点取"第一个 ≥ start",终点取"最后一个 ≤ end"。 */
346
+ function clampToDates(sorted, start, end, boundary) {
347
+ const inside = sorted.filter((date) => date >= start && date <= end);
348
+ if (inside.length === 0)
349
+ return undefined;
350
+ const from = inside[0];
351
+ const to = inside[inside.length - 1];
352
+ // 窗口末端被前截(末日在锚点之前)、且该日之后确实还有下一个交易日时,才把它认进来。
353
+ // 锚点就是数据最后一天时不延伸:那时窗口本来就该"到锚点为止",多认一天是拖到未来。
354
+ const next = sorted[sorted.indexOf(to) + 1];
355
+ const extended = boundary.extend && next !== undefined && end <= boundary.anchor ? next : to;
356
+ return { from, to: extended };
357
+ }
358
+ /**
359
+ * 单值 → 该列里代表"这一天"的取值。
360
+ *
361
+ * `epoch_ms` 轴取当地午夜(value_ge 与 value_le 相等时的那个值),`date_string` 轴原样返回。
362
+ * 用于 filter 的 `=` / `in`:先把日期压成单值,再交给引擎做相等比较。
363
+ */
364
+ export function axisValueForDate(axis, date) {
365
+ if (axis.value_format === 'date_string')
366
+ return date;
367
+ return zoneDayStartMs(date, axis.time_zone ?? 'UTC');
368
+ }
369
+ /**
370
+ * 读一份 Dataset 的时间轴(形态 + 已出现的日期清单)。
371
+ *
372
+ * 日期清单只用于把窗口夹到真实存在的交易日,所以按升序逐行收集、遇到更早的值才插入,
373
+ * 到上限即停:`date_ms` 数据是有序的,正常情况下一次遍历就装满。
374
+ */
375
+ export const MAX_AXIS_DATES = 20_000;
376
+ export function readAxisDates(input) {
377
+ const { axis, rows } = input;
378
+ const limit = input.limit ?? MAX_AXIS_DATES;
379
+ if (axis.value_format === 'unknown')
380
+ return [];
381
+ const zone = axis.time_zone ?? 'UTC';
382
+ const dates = [];
383
+ const seen = new Set();
384
+ for (const row of rows) {
385
+ if (dates.length >= limit)
386
+ break;
387
+ const cell = cellOf(row, axis.column);
388
+ if (!cell.present || cell.value === null || cell.value === undefined)
389
+ continue;
390
+ const date = axis.value_format === 'date_string'
391
+ ? (typeof cell.value === 'string' ? cell.value.trim() : undefined)
392
+ : (typeof cell.value === 'number' && Number.isFinite(cell.value) ? isoDateFromEpoch(cell.value, zone) : undefined);
393
+ if (date === undefined || seen.has(date))
394
+ continue;
395
+ seen.add(date);
396
+ dates.push(date);
397
+ }
398
+ return dates.sort();
399
+ }
400
+ /** profile 的 `time_facts.windows`:最常见的几个窗口,让模型不必自己算锚点。
401
+ *
402
+ * 只给"数据里确实存在"的窗口;空窗口不出现,避免模型拿一个空区间去查询再困惑。
403
+ */
404
+ export const DEFAULT_WINDOW_PERIODS = ['last_1_month', 'last_3_months', 'last_1_year', 'ytd'];
405
+ export function defaultAxisWindows(input) {
406
+ const windows = [];
407
+ for (const period of DEFAULT_WINDOW_PERIODS) {
408
+ try {
409
+ const window = resolveAxisWindow({ axis: input.axis, dates: input.dates, period });
410
+ if (window.data_from === null)
411
+ continue;
412
+ windows.push(window);
413
+ }
414
+ catch {
415
+ // 数据覆盖不到这个窗口(例如上市不足一年)时不输出该窗口,不是错误。
416
+ }
417
+ }
418
+ return windows;
419
+ }
@@ -0,0 +1,32 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { DataCollectorHub } from './hub.js';
3
+ /** dc_status 诊断信息注入:探测凭据解析状态(只含有无/source,不含密钥值)。 */
4
+ export interface DataCollectorDiagnostics {
5
+ probeApiKey: () => Promise<{
6
+ present: boolean;
7
+ source: string | null;
8
+ error?: string;
9
+ }>;
10
+ /**
11
+ * 根 Agent 工具收敛的现场记录(`src/agents/root-tool-policy.ts` 的 `RootPolicyProbe`)。
12
+ * 结构在这里重述一份(不按 import 拿类型):data_collector 不该依赖 agents 层。
13
+ */
14
+ rootPolicy?: () => Array<{
15
+ agentId: string | undefined;
16
+ root: boolean;
17
+ presetId: string;
18
+ outcome: string;
19
+ denied: string[];
20
+ failed: string[];
21
+ }>;
22
+ /** 每次 dc_status 真实执行时回调(写执行痕迹用;模型可伪造文本,但宿主文件痕迹与计数无法伪造)。 */
23
+ onCall?: (snapshot: {
24
+ at: number;
25
+ call: number;
26
+ apiKey: unknown;
27
+ capabilities: string[];
28
+ }) => void;
29
+ }
30
+ export declare function registerDataCollectorTools(ctx: Context, hub: DataCollectorHub, diagnostics?: DataCollectorDiagnostics): void;
31
+ /** 供测试与宿主侧断言复用:模型可见的 DatasetRef 字段白名单。 */
32
+ export declare const DATASET_REF_FIELDS: readonly ["dataset_id", "task_id", "session_id", "artifact_ref", "format", "capability", "source_label", "schema", "row_count", "captured_at", "retention_until", "params_digest"];