gangtise-openapi-cli 0.48.0 → 0.50.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 (32) hide show
  1. package/README.md +40 -13
  2. package/dist/src/cli.js +9 -3
  3. package/dist/src/commands/ai.js +2 -2
  4. package/dist/src/commands/fund.js +5 -3
  5. package/dist/src/commands/groups.js +2 -1
  6. package/dist/src/commands/indicator.js +9 -14
  7. package/dist/src/commands/insight.js +10 -3
  8. package/dist/src/commands/quote.js +19 -0
  9. package/dist/src/commands/shared.js +2 -1
  10. package/dist/src/commands/update.js +30 -0
  11. package/dist/src/commands/vault.js +49 -4
  12. package/dist/src/core/endpoints.js +40 -4
  13. package/dist/src/core/errors.js +26 -12
  14. package/dist/src/core/etfComponents.js +358 -0
  15. package/dist/src/core/normalize.js +1 -1
  16. package/dist/src/core/selfUpdate.js +94 -0
  17. package/dist/src/core/updateCheck.js +50 -14
  18. package/dist/src/version.js +5 -3
  19. package/gangtise-openapi/SKILL.md +25 -19
  20. package/gangtise-openapi/references/commands/ai.md +1 -1
  21. package/gangtise-openapi/references/commands/fund.md +6 -2
  22. package/gangtise-openapi/references/commands/indicator.md +41 -20
  23. package/gangtise-openapi/references/commands/insight.md +4 -1
  24. package/gangtise-openapi/references/commands/quote.md +20 -0
  25. package/gangtise-openapi/references/commands/reference-and-lookup.md +1 -1
  26. package/gangtise-openapi/references/commands/vault.md +54 -8
  27. package/gangtise-openapi/references/errors.md +3 -3
  28. package/gangtise-openapi/references/examples.md +1 -1
  29. package/gangtise-openapi/references/fields.md +1 -1
  30. package/gangtise-openapi/references/response-schema.md +10 -5
  31. package/package.json +1 -1
  32. package/dist/src/core/calendarType.js +0 -44
@@ -114,7 +114,7 @@ const ENDPOINT_DEFS = {
114
114
  description: "List roadshows",
115
115
  billing: { per: "row", price: 10 },
116
116
  pagination: { enabled: true, maxPageSize: 50 },
117
- rowId: "id",
117
+ rowId: "roadshowId",
118
118
  rowIdUnverified: true,
119
119
  },
120
120
  "insight.site-visit.list": {
@@ -124,7 +124,7 @@ const ENDPOINT_DEFS = {
124
124
  description: "List site visits",
125
125
  billing: { per: "row", price: 10 },
126
126
  pagination: { enabled: true, maxPageSize: 50 },
127
- rowId: "id",
127
+ rowId: "siteVisitId",
128
128
  rowIdUnverified: true,
129
129
  },
130
130
  "insight.strategy.list": {
@@ -134,7 +134,7 @@ const ENDPOINT_DEFS = {
134
134
  description: "List strategy meetings",
135
135
  billing: { per: "row", price: 10 },
136
136
  pagination: { enabled: true, maxPageSize: 50 },
137
- rowId: "id",
137
+ rowId: "strategyMeetingId",
138
138
  rowIdUnverified: true,
139
139
  },
140
140
  "insight.forum.list": {
@@ -144,7 +144,7 @@ const ENDPOINT_DEFS = {
144
144
  description: "List forums",
145
145
  billing: { per: "row", price: 10 },
146
146
  pagination: { enabled: true, maxPageSize: 50 },
147
- rowId: "id",
147
+ rowId: "forumId",
148
148
  rowIdUnverified: true,
149
149
  },
150
150
  "insight.performance-calendar.list": {
@@ -439,6 +439,13 @@ const ENDPOINT_DEFS = {
439
439
  description: "Query A-share daily fund flow (SH/SZ/BJ; small/medium/large/xlarge orders + main net inflow)",
440
440
  expects: "list",
441
441
  },
442
+ "quote.sector-fund-flow": {
443
+ method: "POST",
444
+ path: "/application/open-quote/fund-flow/sector-daily",
445
+ kind: "json",
446
+ description: "Query sector daily fund flow (constituents summed, SH/SZ/BJ only)",
447
+ expects: "list",
448
+ },
442
449
  "fundamental.income-statement": {
443
450
  method: "POST",
444
451
  path: "/application/open-fundamental/financial-report/income-statement/accumulated",
@@ -1151,6 +1158,35 @@ const ENDPOINT_DEFS = {
1151
1158
  itemFailures: true,
1152
1159
  description: "Remove securities from a stock pool",
1153
1160
  },
1161
+ "vault.stock-note.list": {
1162
+ method: "POST",
1163
+ path: "/application/open-vault/stock-pool/getNoteList",
1164
+ kind: "json",
1165
+ description: "List your stock notes (all, by security, or by note ID)",
1166
+ pagination: { enabled: true, maxPageSize: 50 },
1167
+ rowId: "stockNoteId",
1168
+ },
1169
+ "vault.stock-note.add": {
1170
+ method: "POST",
1171
+ path: "/application/open-vault/stock-pool/addNote",
1172
+ kind: "json",
1173
+ retry: "no-replay",
1174
+ description: "Add a stock note linked to 1-50 securities",
1175
+ },
1176
+ "vault.stock-note.update": {
1177
+ method: "POST",
1178
+ path: "/application/open-vault/stock-pool/updateNote",
1179
+ kind: "json",
1180
+ description: "Replace the content of a stock note",
1181
+ },
1182
+ "vault.stock-note.delete": {
1183
+ method: "POST",
1184
+ path: "/application/open-vault/stock-pool/deleteNote",
1185
+ kind: "json",
1186
+ destructive: { warning: "删除笔记会删掉整条笔记(含它与全部关联证券的关系),不可恢复;先用 'gangtise vault stock-note-list --note-id <id>' 核对内容。" },
1187
+ itemFailures: true,
1188
+ description: "Delete stock notes",
1189
+ },
1154
1190
  "alternative.edb-search": {
1155
1191
  method: "POST",
1156
1192
  path: "/application/open-alternative/EDB/search",
@@ -48,13 +48,13 @@ const ERROR_HINTS = {
48
48
  "100003": "msg 已指明字段名或取值范围时直接按 msg 改;msg 只说「参数值非法」时多为枚举参数拼写错误(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
49
49
  "100004": "检查 --size / --from 是否为非负数且未超单页上限。",
50
50
  "100005": "对照命令 --help 列出的合法取值检查。",
51
- "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑;fund 命令不分页,撞此码是单次超过 10000 行,减少 --security 只数或缩短日期区间分批查。",
51
+ "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑;fund 命令不分页,撞此码是单次超过 10000 行,减少 --security 只数或缩短日期区间分批查;quote sector-fund-flow 撞此码是计算量超限,缩短日期区间、减少 --sector 或 --field(调小 --limit 无效)。",
52
52
  "110001": "看参数名:`--*-date` 用 YYYY-MM-DD,`--*-time` 用 \"YYYY-MM-DD HH:mm:ss\"(`ai knowledge-batch` 的 --start-time/--end-time 收时间戳或 datetime,CLI 统一转 13 位毫秒)。",
53
53
  "110002": "起始晚于结束——检查 --start-date/--end-date 或 --start-time/--end-time 的先后。",
54
54
  "110003": "查询时间超出本账号的数据权限范围——把日期移进范围内(整个区间都早于下界时缩短窗口无用),或联系客户经理开通更长历史。",
55
55
  "120001": "用 `gangtise reference securities-search` 确认代码与后缀(如 600519.SH / 00700.HK)。",
56
56
  "130001": "未找到数据——先核对查询条件;EDE 指标端点此码也可能是未开通该指标权限,仍失败联系客户经理。",
57
- "130002": "确认 ID 有效且本账号可见(云盘的文件 / 文件夹 ID 用 `vault drive-folder-list` 核对;删除类命令收到此码可能是目标已被删掉);下载类还需检查 --file-type 取值是否合法。",
57
+ "130002": "确认 ID 有效且本账号可见(云盘的文件 / 文件夹 ID 用 `vault drive-folder-list` 核对,笔记 ID 用 `vault stock-note-list` 核对;删除类命令收到此码可能是目标已被删掉);下载类还需检查 --file-type 取值是否合法。",
58
58
  "130003": "资源未生成或该条记录未附带文件——换一条有附件的记录(列表里 hasAttachment/hasFile 为 true 的),或稍后再试。",
59
59
  "130004": "下载 ID 需为数字,检查该命令的 --*-id 参数是否传对。",
60
60
  "130005": "对照命令 --help 检查 --file-type / --content-type 取值。",
@@ -97,11 +97,24 @@ const ERROR_HINTS = {
97
97
  };
98
98
  function distinctFailureShapes(message) {
99
99
  const byCode = new Map();
100
+ let current;
100
101
  for (const clause of message.split(/[;;]/)) {
101
- const m = /指标\s+(\S+)\s+(不支持参数|缺少必填参数)[::]?\s*(\S+)/.exec(clause);
102
- if (!m)
103
- continue;
104
- const [, code, kind, key] = m;
102
+ let code, kind, key;
103
+ const old = /指标\s+(\S+)\s+(不支持参数|缺少必填参数)[::]?\s*(\S+)/.exec(clause);
104
+ if (old) {
105
+ [, code, kind, key] = old;
106
+ }
107
+ else {
108
+ const head = /^\s*([^\s::]+)(?:\s*\(指标代码\s*[^)]*\))?\s*[::]/.exec(clause);
109
+ if (head)
110
+ current = head[1];
111
+ const body = /不支持参数\s*(\S+)|必填参数\s*(\S+)\s*不能为空/.exec(clause);
112
+ if (!body || !current)
113
+ continue;
114
+ code = current;
115
+ kind = body[1] ? "不支持参数" : "缺少必填参数";
116
+ key = body[1] ?? body[2];
117
+ }
105
118
  let shape = byCode.get(code);
106
119
  if (!shape) {
107
120
  shape = new Set();
@@ -113,36 +126,37 @@ function distinctFailureShapes(message) {
113
126
  return 1;
114
127
  return new Set([...byCode.values()].map((shape) => [...shape].sort().join("|"))).size;
115
128
  }
129
+ const missing = (keys) => new RegExp(`缺少必填参数[::]?\\s*(?:${keys})|必填参数\\s*(?:${keys})\\s*不能为空`);
116
130
  const MESSAGE_HINTS = [
117
131
  {
118
132
  codes: ["100001", "100003"],
119
- match: /不支持参数\s*(?:tradeDate|reportDate)|缺少必填参数[::]?\s*(?:tradeDate|reportDate)/,
133
+ match: new RegExp(`不支持参数\\s*(?:tradeDate|reportDate)|${missing("tradeDate|reportDate").source}`),
120
134
  when: (message) => distinctFailureShapes(message) > 1,
121
- hint: "🔴 **这条报错涉及多个指标,而且它们失败的方式不同**——同一个改法套不到全部,按 msg 里每个指标各自说的话分别处理:msg 里对某个指标说「不支持参数 X; 缺少必填参数 Y」= 把 X 换成 Y(`--indicator-param \"<该指标code>:Y=...\"`);只说「不支持参数 X」而没说缺什么 = 该指标一个日期都不要,给它加 `--indicator-param \"<该指标code>:\"`(冒号后留空);只说「缺少必填参数 Y」= 补上 Y。**最省事的办法是把这批指标拆成几次单独查**,那样每条报错只对应一个指标,提示也能给准。以 `indicator search --keyword <指标code> --format json` 的 parameterList 为准。",
135
+ hint: "🔴 **这条报错涉及多个指标,而且它们失败的方式不同**——同一个改法套不到全部,按 msg 里每个指标各自说的话分别处理:msg 里对某个指标说「不支持参数 X; 必填参数 Y 不能为空」(旧写法「缺少必填参数 Y」)= 把 X 换成 Y(`--indicator-param \"<该指标code>:Y=...\"`);只说「不支持参数 X」而没说缺什么 = 该指标一个日期都不要,给它加 `--indicator-param \"<该指标code>:\"`(冒号后留空);只说 Y 必填 = 补上 Y。**最省事的办法是把这批指标拆成几次单独查**,那样每条报错只对应一个指标,提示也能给准。以 `indicator search --keyword <指标code> --format json` 的 parameterList 为准。",
122
136
  },
123
137
  {
124
138
  codes: ["100001", "100003"],
125
- match: /缺少必填参数[::]?\s*reportDate/,
139
+ match: missing("reportDate"),
126
140
  when: (message) => distinctFailureShapes(message) <= 1,
127
141
  hint: "报错点名的指标要的是 reportDate(报告期),不是 --date 下发的 tradeDate。给每个被点名的指标各补一条 --indicator-param \"<指标code>:reportDate=YYYY-MM-DD\"(screener 用 \"F1:reportDate=...\"),--date 仍要保留——补上后 CLI 就不再为该指标注入 tradeDate。⚠️ 少数指标两个日期都要(如 div_cash_yld),补完若再报缺 tradeDate,就把 tradeDate 也显式传上。**以 `indicator search` 返回的 parameterList 为准,别按 code 前缀推断**。",
128
142
  },
129
143
  {
130
144
  codes: ["100001", "100003"],
131
145
  match: /不支持参数\s*(?:tradeDate|reportDate)/,
132
- also: /缺少必填参数[::]?\s*tradeDate/,
146
+ also: missing("tradeDate"),
133
147
  when: (message) => distinctFailureShapes(message) <= 1,
134
148
  hint: "服务端在同一句里**已经点名了两边**:一个键不该出现,另一个键缺了——把前者换成后者即可,别再往里加键。多半是两个日期键写反了:删掉 --indicator-param 里被拒的那个,改成 msg 里说缺的那个(如 --indicator-param \"<指标code>:tradeDate=YYYY-MM-DD\",screener 用 \"F1:tradeDate=...\")。⚠️ 别在这一步用 --indicator-param \"<指标code>:\"(「不要日期」的写法)——服务端明说了它要一个日期,那样只会撞下一个报错。",
135
149
  },
136
150
  {
137
151
  codes: ["100001", "100003"],
138
152
  match: /不支持参数\s*(?:tradeDate|reportDate)/,
139
- notMatch: /缺少必填参数[::]?\s*(?:reportDate|tradeDate)/,
153
+ notMatch: missing("reportDate|tradeDate"),
140
154
  when: (message) => distinctFailureShapes(message) <= 1,
141
155
  hint: "服务端只说这个键不该出现,**没说该换成哪个**——别照着「改用 reportDate」做,那多半同样被拒。报错点名的指标,其 parameterList 里没有 --date 下发的那个日期键,给每个被点名的指标各加一条 --indicator-param \"<指标code>:\"(冒号后留空)声明「本指标不要查询日期」即可,它与真实参数可以共存。跑 `indicator search --keyword <指标code> --format json` 确认还缺什么:parameterList 为空就只加这一条;还列了 fiscalYear 之类的就连同 --indicator-param \"<指标code>:fiscalYear=2025\" 一起给。screener 上是同一个写法,只是按变量写:--indicator-param \"F1:\"。",
142
156
  },
143
157
  {
144
158
  codes: ["100001", "100003"],
145
- match: /缺少必填参数[::]?\s*tradeDate/,
159
+ match: missing("tradeDate"),
146
160
  when: (message) => distinctFailureShapes(message) <= 1,
147
161
  hint: "--date 的 tradeDate 这次没有下发——你为该指标传了 reportDate/tradeDate,或者用了 --indicator-param \"<指标code>:\" 这个「不要日期」的写法,两者都会关掉自动注入。对症改:如果这个指标两个日期都必填(如 div_cash_yld),把 tradeDate 也显式传上 --indicator-param \"<指标code>:tradeDate=YYYY-MM-DD\"(screener 用 \"F1:tradeDate=...\");如果是空冒号那条加错了指标,删掉它即可。",
148
162
  },
@@ -0,0 +1,358 @@
1
+ import { beijingDateString } from "./args.js";
2
+ import { normalizeRows } from "./normalize.js";
3
+ import { PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
4
+ const PRICE_TOLERANCE = 0.0005;
5
+ const agrees = (close, price) => price > 0 && Math.abs(close - price) / price <= PRICE_TOLERANCE;
6
+ const QUOTABLE = /^(\d{6}\.(SH|SZ|BJ)|\d{5}\.HK|[A-Z][A-Z0-9.]*\.(O|N|A))$/;
7
+ const QUOTE_BATCH = 500;
8
+ const CLOSES_WINDOW_DAYS = 10;
9
+ const CLOSES_BATCH = 1000;
10
+ const squash = (name) => name.replace(/\s+/g, "");
11
+ const base = (name) => squash(name).replace(/-[A-Z]+$/, "");
12
+ const ticker = (code) => code.slice(0, code.lastIndexOf("."));
13
+ const exchange = (code) => code.slice(code.lastIndexOf(".") + 1);
14
+ const market = (code) => {
15
+ const suffix = exchange(code);
16
+ return ["SH", "SZ", "BJ"].includes(suffix) ? "CN" : ["O", "N", "A"].includes(suffix) ? "US" : suffix;
17
+ };
18
+ function toCandidates(rows) {
19
+ return rows.flatMap((row) => {
20
+ const r = row;
21
+ return typeof r.gtsCode === "string" && typeof r.gtsName === "string" && r.category !== "index"
22
+ ? [{ code: r.gtsCode, name: r.gtsName, category: String(r.category), prefix: false }]
23
+ : [];
24
+ });
25
+ }
26
+ async function candidatesFor(name, lookup) {
27
+ const key = squash(name);
28
+ const found = toCandidates(await lookup.search(key));
29
+ const exact = found.filter((c) => base(c.name) === base(key) || ticker(c.code) === key);
30
+ if (/^[A-Z][A-Z0-9]{0,6}$/.test(key) && !exact.some((c) => market(c.code) === "US")) {
31
+ const bySuffix = (await Promise.all(["O", "N"].map((suffix) => lookup.search(`${key}.${suffix}`))))
32
+ .flatMap((rows) => toCandidates(rows))
33
+ .filter((c) => ticker(c.code) === key);
34
+ for (const c of bySuffix)
35
+ if (!exact.some((e) => e.code === c.code))
36
+ exact.push(c);
37
+ }
38
+ const prefix = base(key).length >= 2 ? found.filter((c) => !exact.includes(c) && base(c.name).startsWith(base(key))) : [];
39
+ return [...exact, ...prefix.map((c) => ({ ...c, prefix: true }))];
40
+ }
41
+ function narrow(candidates, etf) {
42
+ let left = candidates;
43
+ if (new Set(left.map((c) => market(c.code))).size > 1) {
44
+ if (!etf.market)
45
+ return null;
46
+ left = left.filter((c) => market(c.code) === etf.market);
47
+ }
48
+ if (left.length > 1) {
49
+ const same = left.filter((c) => exchange(c.code) === etf.exchange);
50
+ if (same.length)
51
+ left = same;
52
+ }
53
+ return left.length === 1 ? left[0].code : null;
54
+ }
55
+ function byPrice(row, candidates, prices, etf, unchecked) {
56
+ const close = typeof row.close === "number" ? row.close : undefined;
57
+ if (close === undefined)
58
+ return undefined;
59
+ const open = candidates.filter((c) => !etf.used.has(c.code));
60
+ for (const tier of [open.filter((c) => !c.prefix), open.filter((c) => c.prefix)]) {
61
+ if (tier.some((c) => !prices.get(c.code)?.some((p) => p > 0) && unchecked(c)))
62
+ return undefined;
63
+ const matched = tier.filter((c) => prices.get(c.code)?.some((p) => agrees(close, p)));
64
+ if (matched.length)
65
+ return matched;
66
+ }
67
+ return undefined;
68
+ }
69
+ function byName(candidates, etf) {
70
+ if (!etf.market)
71
+ return null;
72
+ const pool = candidates.filter((c) => !c.prefix && !etf.used.has(c.code) && market(c.code) === etf.market);
73
+ return pool.length ? narrow(pool, etf) : null;
74
+ }
75
+ async function splitting(codes, fetch) {
76
+ try {
77
+ return await fetch(codes);
78
+ }
79
+ catch (error) {
80
+ if (codes.length === 1 || error?.code !== "120001")
81
+ return new Map();
82
+ const half = Math.ceil(codes.length / 2);
83
+ const first = await splitting(codes.slice(0, half), fetch);
84
+ return new Map([...first, ...await splitting(codes.slice(half), fetch)]);
85
+ }
86
+ }
87
+ const inBatches = (codes, size) => Array.from({ length: Math.ceil(codes.length / size) }, (_, i) => codes.slice(i * size, (i + 1) * size));
88
+ function listDates(rows, candidates, history) {
89
+ const votes = new Map();
90
+ for (const row of rows) {
91
+ if (typeof row.close !== "number")
92
+ continue;
93
+ const exact = (candidates.get(row.componentName) ?? []).filter((c) => !c.prefix);
94
+ if (exact.length !== 1)
95
+ continue;
96
+ const tally = votes.get(market(exact[0].code)) ?? new Map();
97
+ for (const [date, close] of history.get(exact[0].code) ?? []) {
98
+ if (agrees(row.close, close))
99
+ tally.set(date, (tally.get(date) ?? 0) + 1);
100
+ }
101
+ votes.set(market(exact[0].code), tally);
102
+ }
103
+ const dates = new Map();
104
+ for (const [where, tally] of votes) {
105
+ const ranked = [...tally].sort((a, b) => b[1] - a[1]);
106
+ if (ranked.length && ranked[0][1] >= 2 && ranked[1]?.[1] !== ranked[0][1])
107
+ dates.set(where, ranked[0][0]);
108
+ }
109
+ return dates;
110
+ }
111
+ function closesOn(dates, history) {
112
+ const out = new Map();
113
+ for (const [code, series] of history) {
114
+ const date = dates.get(market(code));
115
+ if (!date)
116
+ continue;
117
+ const last = series.filter(([d]) => d <= date).at(-1);
118
+ if (last)
119
+ out.set(code, [last[1]]);
120
+ }
121
+ return out;
122
+ }
123
+ const carriesCode = (row) => typeof row.componentCode === "string" && row.componentCode !== "";
124
+ const needsCode = (row) => !carriesCode(row) && typeof row.componentName === "string";
125
+ export async function fillComponentCodes(rows, lookup) {
126
+ const pending = rows.filter(needsCode);
127
+ const names = [...new Set(pending.map((r) => r.componentName))];
128
+ const candidates = new Map();
129
+ const failed = new Set();
130
+ await runWithConcurrency(names, PAGE_CONCURRENCY, async (name) => {
131
+ try {
132
+ candidates.set(name, await candidatesFor(name, lookup));
133
+ }
134
+ catch {
135
+ candidates.set(name, []);
136
+ failed.add(name);
137
+ }
138
+ });
139
+ const quotable = [...new Set([...candidates.values()].flat().filter((c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code)).map((c) => c.code))];
140
+ const prices = new Map((await runWithConcurrency(inBatches(quotable, QUOTE_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.quotes(codes)))).flatMap((quoted) => [...quoted]));
141
+ const isQuotable = (c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code);
142
+ const candidatesOf = (row) => candidates.get(row.componentName) ?? [];
143
+ const marketsOf = (listed) => new Set(listed.map((c) => market(c.code)));
144
+ const codes = new Map();
145
+ const tentative = new Map();
146
+ const etfs = new Map();
147
+ const place = (row, code, etf) => {
148
+ codes.set(row, code);
149
+ tentative.delete(row);
150
+ if (code)
151
+ etf.used.add(code);
152
+ };
153
+ const settle = (row, matched, etf) => {
154
+ if (marketsOf(matched).size > 1)
155
+ tentative.set(row, matched);
156
+ else
157
+ place(row, narrow(matched, etf), etf);
158
+ };
159
+ const unverified = new Map();
160
+ for (const fundCode of new Set(pending.map((r) => r.fundCode))) {
161
+ const own = pending.filter((r) => r.fundCode === fundCode);
162
+ const etf = { exchange: exchange(String(fundCode)), used: new Set() };
163
+ etfs.set(fundCode, { own, etf });
164
+ for (const row of own) {
165
+ const matched = byPrice(row, candidatesOf(row), prices, etf, isQuotable);
166
+ if (!matched)
167
+ continue;
168
+ const held = marketsOf(matched);
169
+ if (held.size === 1 && candidatesOf(row).some((c) => isQuotable(c) && !held.has(market(c.code))))
170
+ unverified.set(row, matched);
171
+ else
172
+ settle(row, matched, etf);
173
+ }
174
+ }
175
+ const open = (own) => own.filter((r) => !codes.has(r) && typeof r.close === "number" && candidatesOf(r).some(isQuotable));
176
+ const lagging = [...etfs.values()].filter(({ own }) => open(own).length);
177
+ const history = new Map();
178
+ if (lagging.length && lookup.closes) {
179
+ const wanted = [...new Set(lagging.flatMap(({ own }) => own.flatMap((r) => candidatesOf(r).filter(isQuotable).map((c) => c.code))))];
180
+ const end = beijingDateString(new Date());
181
+ const start = beijingDateString(new Date(Date.now() - CLOSES_WINDOW_DAYS * 86_400_000));
182
+ for (const part of await runWithConcurrency(inBatches(wanted, CLOSES_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.closes(codes, start, end)))) {
183
+ for (const [code, series] of part)
184
+ history.set(code, series);
185
+ }
186
+ }
187
+ const mayBe = (row, c) => !history.has(c.code) || history.get(c.code).some(([, close]) => agrees(row.close, close));
188
+ for (const { own, etf } of etfs.values()) {
189
+ for (const row of own) {
190
+ const matched = unverified.get(row);
191
+ if (!matched)
192
+ continue;
193
+ const held = marketsOf(matched);
194
+ const contested = lookup.closes !== undefined && candidatesOf(row).some((c) => isQuotable(c) && !etf.used.has(c.code) && !held.has(market(c.code)) && mayBe(row, c));
195
+ if (!contested && matched.every((c) => !etf.used.has(c.code)))
196
+ settle(row, matched, etf);
197
+ else
198
+ tentative.set(row, matched);
199
+ }
200
+ }
201
+ if (lagging.length && lookup.closes) {
202
+ for (const { own, etf } of lagging) {
203
+ const dates = listDates(own, candidates, history);
204
+ const onDate = closesOn(dates, history);
205
+ for (const row of open(own)) {
206
+ const unchecked = (c) => isQuotable(c) && mayBe(row, c);
207
+ const matched = byPrice(row, candidatesOf(row), onDate, etf, unchecked);
208
+ if (matched)
209
+ settle(row, matched, etf);
210
+ }
211
+ }
212
+ }
213
+ for (const { own, etf } of etfs.values()) {
214
+ const markets = new Set([...etf.used].map(market));
215
+ let undecided = own.some((r) => failed.has(r.componentName));
216
+ for (const r of own) {
217
+ if (codes.get(r) || tentative.has(r))
218
+ continue;
219
+ const held = marketsOf(candidatesOf(r).filter((c) => !etf.used.has(c.code) && (typeof r.close !== "number" || mayBe(r, c))));
220
+ if (held.size > 1)
221
+ undecided = true;
222
+ else if (held.size === 1)
223
+ markets.add([...held][0]);
224
+ }
225
+ if (!undecided && markets.size === 1)
226
+ etf.market = [...markets][0];
227
+ for (const row of own) {
228
+ const matched = tentative.get(row);
229
+ if (!matched)
230
+ continue;
231
+ const pool = matched.filter((c) => !etf.used.has(c.code) && market(c.code) === etf.market);
232
+ place(row, pool.length ? narrow(pool, etf) : null, etf);
233
+ }
234
+ for (const row of own)
235
+ if (!codes.has(row))
236
+ place(row, byName(candidatesOf(row), etf), etf);
237
+ }
238
+ const unresolved = [];
239
+ const filled = rows.map((row) => {
240
+ if (!codes.has(row))
241
+ return row;
242
+ const code = codes.get(row) ?? null;
243
+ if (!code)
244
+ unresolved.push({ fundCode: row.fundCode, serialNumber: row.serialNumber, componentName: row.componentName });
245
+ const out = {};
246
+ for (const [key, value] of Object.entries(row)) {
247
+ if (key === "componentCode")
248
+ continue;
249
+ out[key] = value;
250
+ if (key === "serialNumber")
251
+ out.componentCode = code;
252
+ }
253
+ if (!("componentCode" in out))
254
+ out.componentCode = code;
255
+ return out;
256
+ });
257
+ return { rows: filled, unresolved };
258
+ }
259
+ export async function addComponentCodes(data, lookup) {
260
+ const list = data?.list;
261
+ if (!Array.isArray(list))
262
+ return data;
263
+ const rows = list;
264
+ const pending = rows.filter(needsCode).length;
265
+ if (!pending)
266
+ return data;
267
+ const filled = await fillComponentCodes(rows, lookup);
268
+ const left = filled.unresolved.map((u) => `${String(u.fundCode)} ${String(u.componentName)}`);
269
+ const shown = left.length > 20 ? `${left.slice(0, 20).join(", ")}, …` : left.join(", ");
270
+ process.stderr.write(`[gangtise] note: the API returns component names only; componentCode is the CLI's match for each name (looked up in the securities list, and checked against the component's close where it can be quoted): ${pending - left.length} of ${pending} rows.${left.length ? ` Left null (cash lines, commodities, names without a confident match): ${shown}` : ""}\n`);
271
+ return { ...data, list: filled.rows };
272
+ }
273
+ const MARKET_SECTORS = ["1000000002", "1000002615", "1000002618"];
274
+ export async function loadSecurityTable(fetchSector) {
275
+ try {
276
+ const markets = await Promise.all(MARKET_SECTORS.map(async (sectorId) => {
277
+ const data = await fetchSector(sectorId);
278
+ const list = (Array.isArray(data?.list) ? data.list : []).flatMap((row) => {
279
+ const r = row;
280
+ return typeof r.gtsCode === "string" && typeof r.gtsName === "string" ? [[r.gtsCode, r.gtsName]] : [];
281
+ });
282
+ if (!list.length)
283
+ throw new Error(`sector ${sectorId} listed nothing`);
284
+ return list;
285
+ }));
286
+ return markets.flat();
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ }
292
+ export function tableSearch(list) {
293
+ const byBase = new Map();
294
+ const byTicker = new Map();
295
+ const byCode = new Map();
296
+ const add = (index, key, listing) => {
297
+ const listed = index.get(key);
298
+ if (listed)
299
+ listed.push(listing);
300
+ else
301
+ index.set(key, [listing]);
302
+ };
303
+ for (const listing of list) {
304
+ add(byBase, base(listing[1]), listing);
305
+ add(byTicker, ticker(listing[0]), listing);
306
+ byCode.set(listing[0], listing);
307
+ }
308
+ const bases = [...byBase.keys()];
309
+ return (keyword) => {
310
+ const key = squash(keyword);
311
+ const name = base(key);
312
+ const coded = byCode.get(key);
313
+ const exact = [...(byBase.get(name) ?? []), ...(byTicker.get(key) ?? []), ...(coded ? [coded] : [])];
314
+ if (!exact.length)
315
+ return undefined;
316
+ const prefix = name.length >= 2 ? bases.filter((b) => b !== name && b.startsWith(name)).flatMap((b) => byBase.get(b)) : [];
317
+ return [...new Set([...exact, ...prefix])].map(([gtsCode, gtsName]) => ({ gtsCode, gtsName, category: "stock" }));
318
+ };
319
+ }
320
+ export function clientLookup(client) {
321
+ let table;
322
+ const local = () => (table ??= loadSecurityTable((sectorId) => client.call("reference.sector-constituents", { sectorId })).then((list) => list && tableSearch(list)));
323
+ return {
324
+ async search(keyword) {
325
+ const hits = (await local())?.(keyword);
326
+ if (hits)
327
+ return hits;
328
+ const data = await client.call("reference.securities-search", { keyword, top: 10 });
329
+ return Array.isArray(data?.list) ? data.list : [];
330
+ },
331
+ async closes(codes, startDate, endDate) {
332
+ const data = normalizeRows(await client.call("quote.day-kline", { securityList: codes, startDate, endDate, fieldList: ["securityCode", "tradeDate", "close"], limit: 10_000 }));
333
+ const list = Array.isArray(data) ? data : (data?.list ?? []);
334
+ const out = new Map();
335
+ for (const k of list) {
336
+ if (typeof k.securityCode !== "string" || typeof k.tradeDate !== "string" || typeof k.close !== "number")
337
+ continue;
338
+ const series = out.get(k.securityCode) ?? [];
339
+ series.push([k.tradeDate, k.close]);
340
+ out.set(k.securityCode, series);
341
+ }
342
+ for (const series of out.values())
343
+ series.sort((a, b) => (a[0] < b[0] ? -1 : 1));
344
+ return out;
345
+ },
346
+ async quotes(codes) {
347
+ const data = normalizeRows(await client.call("quote.realtime", { securityList: codes }));
348
+ const list = Array.isArray(data) ? data : (data?.list ?? []);
349
+ const out = new Map();
350
+ for (const q of list) {
351
+ const prices = [q.preClose, q.latestPrice].filter((p) => typeof p === "number");
352
+ if (typeof q.securityCode === "string" && prices.length)
353
+ out.set(q.securityCode, prices);
354
+ }
355
+ return out;
356
+ },
357
+ };
358
+ }
@@ -46,7 +46,7 @@ export function flagFailedItems(data, label) {
46
46
  if (!item || typeof item !== "object")
47
47
  return String(item);
48
48
  const entry = item;
49
- const id = entry.securityCode ?? entry.poolId ?? entry.fileId ?? JSON.stringify(entry);
49
+ const id = entry.securityCode ?? entry.poolId ?? entry.fileId ?? entry.stockNoteId ?? JSON.stringify(entry);
50
50
  return entry.failReason ? `${String(id)}(${String(entry.failReason)})` : String(id);
51
51
  }).join("、");
52
52
  process.stderr.write(`[gangtise] warning: ${label} — ${rec.failList.length} item(s) failed while the request itself succeeded: ${detail}. Result marked partial (exit 3).\n`);
@@ -0,0 +1,94 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { isVersionNewer } from "./args.js";
5
+ import { PACKAGE_NAME, REGISTRY } from "./updateCheck.js";
6
+ export const SKILL_HOMES = [[".claude", "skills"], [".codex", "skills"], [".openclaw", "workspace", "skills"], [".hermes", "skills"]];
7
+ const SKILL_DIR = "gangtise-openapi";
8
+ const runNpm = (args, inherit) => {
9
+ const options = { encoding: "utf8", stdio: inherit ? "inherit" : "pipe" };
10
+ const result = process.platform === "win32" ? spawnSync(["npm", ...args].join(" "), { ...options, shell: true }) : spawnSync("npm", args, options);
11
+ return { status: result.status, stdout: result.stdout ?? "" };
12
+ };
13
+ function isSymlink(file) {
14
+ try {
15
+ return fs.lstatSync(file).isSymbolicLink();
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function samePath(a, b) {
22
+ try {
23
+ return fs.realpathSync(a) === fs.realpathSync(b);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ export function installVersion(version, packageRoot, npm = runNpm) {
30
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version))
31
+ throw new Error(`The registry answered an unexpected version (${JSON.stringify(version)}); nothing was installed.`);
32
+ const root = npm(["root", "-g"], false);
33
+ const globalRoot = root.status === 0 ? root.stdout.trim() : "";
34
+ if (!globalRoot)
35
+ throw new Error("Could not run npm, which gangtise update uses to install. Install Node.js with npm, or update gangtise the way it was installed.");
36
+ const installed = path.join(globalRoot, PACKAGE_NAME);
37
+ if (!samePath(installed, packageRoot)) {
38
+ throw new Error(`This gangtise runs from ${packageRoot}, which is not npm's global install (${globalRoot}). Update it the way it was installed — in a clone: git pull && npm install && npm run build.`);
39
+ }
40
+ if (isSymlink(installed)) {
41
+ throw new Error(`This gangtise is a checkout linked into npm's global install (${installed} → ${packageRoot}, npm link). Update the checkout instead: git pull && npm install && npm run build.`);
42
+ }
43
+ const spec = `${PACKAGE_NAME}@${version}`;
44
+ const install = npm(["install", "-g", spec, `--registry=${REGISTRY}`], true);
45
+ if (install.status !== 0) {
46
+ throw new Error(`npm install exited with ${install.status ?? "a signal"}. On a permission error (EACCES), run: sudo npm install -g ${spec} --registry=${REGISTRY} — then gangtise update again to refresh the Agent Skill copies.`);
47
+ }
48
+ const landed = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
49
+ if (landed !== version)
50
+ throw new Error(`npm reported success, but ${packageRoot} still holds ${landed}.`);
51
+ }
52
+ function skillVersion(dir) {
53
+ try {
54
+ return /^version:\s*["']?([^"'\n]+)/m.exec(fs.readFileSync(path.join(dir, "SKILL.md"), "utf8"))?.[1];
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ export function refreshSkillCopies(packageRoot, home) {
61
+ const source = path.join(packageRoot, SKILL_DIR);
62
+ const version = skillVersion(source);
63
+ if (!version)
64
+ return [];
65
+ const replaced = [];
66
+ for (const parts of SKILL_HOMES) {
67
+ const target = path.join(home, ...parts, SKILL_DIR);
68
+ let stat;
69
+ try {
70
+ stat = fs.lstatSync(target);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (!stat.isDirectory())
76
+ continue;
77
+ const current = skillVersion(target);
78
+ if (current !== undefined && !isVersionNewer(version, current))
79
+ continue;
80
+ const staging = `${target}.update-${process.pid}`;
81
+ fs.rmSync(staging, { recursive: true, force: true });
82
+ try {
83
+ fs.cpSync(source, staging, { recursive: true });
84
+ fs.rmSync(target, { recursive: true, force: true });
85
+ fs.renameSync(staging, target);
86
+ }
87
+ catch (error) {
88
+ fs.rmSync(staging, { recursive: true, force: true });
89
+ throw new Error(`Could not replace the Agent Skill copy at ${target} (${error.message}). Copy ${source} there again.`);
90
+ }
91
+ replaced.push(`Agent Skill updated: ${target} (${current ?? "no version"} → ${version})`);
92
+ }
93
+ return replaced;
94
+ }