gangtise-openapi-cli 0.49.0 → 0.51.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/README.md +45 -8
- package/dist/src/commands/ai.js +16 -1
- package/dist/src/commands/indicator.js +8 -13
- package/dist/src/commands/insight.js +10 -3
- package/dist/src/commands/quote.js +19 -0
- package/dist/src/commands/reference.js +24 -1
- package/dist/src/commands/vault.js +51 -5
- package/dist/src/core/client.js +3 -2
- package/dist/src/core/endpoints.js +62 -0
- package/dist/src/core/errors.js +28 -14
- package/dist/src/core/etfComponents.js +96 -19
- package/dist/src/core/normalize.js +1 -1
- package/gangtise-openapi/SKILL.md +35 -22
- package/gangtise-openapi/references/commands/ai.md +26 -0
- package/gangtise-openapi/references/commands/fund.md +2 -2
- package/gangtise-openapi/references/commands/fundamental.md +1 -1
- package/gangtise-openapi/references/commands/indicator.md +94 -25
- package/gangtise-openapi/references/commands/insight.md +4 -1
- package/gangtise-openapi/references/commands/quote.md +20 -0
- package/gangtise-openapi/references/commands/reference-and-lookup.md +21 -1
- package/gangtise-openapi/references/commands/vault.md +79 -11
- package/gangtise-openapi/references/errors.md +2 -2
- package/gangtise-openapi/references/examples.md +1 -1
- package/gangtise-openapi/references/fields.md +1 -1
- package/gangtise-openapi/references/response-schema.md +12 -4
- package/package.json +1 -1
- package/dist/src/core/calendarType.js +0 -44
|
@@ -390,6 +390,13 @@ const ENDPOINT_DEFS = {
|
|
|
390
390
|
kind: "json",
|
|
391
391
|
description: "List constituent securities of a sector",
|
|
392
392
|
},
|
|
393
|
+
"reference.index-weight": {
|
|
394
|
+
method: "POST",
|
|
395
|
+
path: "/application/open-quote/index/constituent-weight",
|
|
396
|
+
kind: "json",
|
|
397
|
+
description: "List index constituents with their weights (%) on one trading date",
|
|
398
|
+
expects: "list",
|
|
399
|
+
},
|
|
393
400
|
"quote.day-kline": {
|
|
394
401
|
method: "POST",
|
|
395
402
|
path: "/application/open-quote/kline/daily",
|
|
@@ -439,6 +446,13 @@ const ENDPOINT_DEFS = {
|
|
|
439
446
|
description: "Query A-share daily fund flow (SH/SZ/BJ; small/medium/large/xlarge orders + main net inflow)",
|
|
440
447
|
expects: "list",
|
|
441
448
|
},
|
|
449
|
+
"quote.sector-fund-flow": {
|
|
450
|
+
method: "POST",
|
|
451
|
+
path: "/application/open-quote/fund-flow/sector-daily",
|
|
452
|
+
kind: "json",
|
|
453
|
+
description: "Query sector daily fund flow (constituents summed, SH/SZ/BJ only)",
|
|
454
|
+
expects: "list",
|
|
455
|
+
},
|
|
442
456
|
"fundamental.income-statement": {
|
|
443
457
|
method: "POST",
|
|
444
458
|
path: "/application/open-fundamental/financial-report/income-statement/accumulated",
|
|
@@ -915,6 +929,18 @@ const ENDPOINT_DEFS = {
|
|
|
915
929
|
pagination: { enabled: true, maxPageSize: 20 },
|
|
916
930
|
retry: "no-replay",
|
|
917
931
|
},
|
|
932
|
+
"ai.expert-intel.list": {
|
|
933
|
+
method: "POST",
|
|
934
|
+
path: "/application/open-insight/expertIntel/getList",
|
|
935
|
+
kind: "json",
|
|
936
|
+
description: "List AI expert intel (takeaways distilled from expert meetings and minutes)",
|
|
937
|
+
billing: { per: "row", price: 20 },
|
|
938
|
+
pagination: { enabled: true, maxPageSize: 50 },
|
|
939
|
+
rowId: "intelId",
|
|
940
|
+
rowIdUnverified: true,
|
|
941
|
+
retry: "no-replay",
|
|
942
|
+
expects: "list",
|
|
943
|
+
},
|
|
918
944
|
"ai.management-discuss-announcement": {
|
|
919
945
|
method: "POST",
|
|
920
946
|
path: "/application/open-ai/management-discuss/from-announcement",
|
|
@@ -1096,6 +1122,13 @@ const ENDPOINT_DEFS = {
|
|
|
1096
1122
|
pagination: { enabled: true, maxPageSize: 50, maxWindow: 10000 },
|
|
1097
1123
|
rowId: "msgId",
|
|
1098
1124
|
},
|
|
1125
|
+
"vault.wechat-message.download": {
|
|
1126
|
+
method: "GET",
|
|
1127
|
+
path: "/application/open-vault/wechatgroupmsg/download/file",
|
|
1128
|
+
kind: "download",
|
|
1129
|
+
description: "Download the file of a WeChat group message (document / image / audio / video)",
|
|
1130
|
+
fileOnSuccess: true,
|
|
1131
|
+
},
|
|
1099
1132
|
"vault.wechat-chatroom.list": {
|
|
1100
1133
|
method: "POST",
|
|
1101
1134
|
path: "/application/open-vault/wechatgroupmsg/chatroomId",
|
|
@@ -1151,6 +1184,35 @@ const ENDPOINT_DEFS = {
|
|
|
1151
1184
|
itemFailures: true,
|
|
1152
1185
|
description: "Remove securities from a stock pool",
|
|
1153
1186
|
},
|
|
1187
|
+
"vault.stock-note.list": {
|
|
1188
|
+
method: "POST",
|
|
1189
|
+
path: "/application/open-vault/stock-pool/getNoteList",
|
|
1190
|
+
kind: "json",
|
|
1191
|
+
description: "List your stock notes (all, by security, or by note ID)",
|
|
1192
|
+
pagination: { enabled: true, maxPageSize: 50 },
|
|
1193
|
+
rowId: "stockNoteId",
|
|
1194
|
+
},
|
|
1195
|
+
"vault.stock-note.add": {
|
|
1196
|
+
method: "POST",
|
|
1197
|
+
path: "/application/open-vault/stock-pool/addNote",
|
|
1198
|
+
kind: "json",
|
|
1199
|
+
retry: "no-replay",
|
|
1200
|
+
description: "Add a stock note linked to 1-50 securities",
|
|
1201
|
+
},
|
|
1202
|
+
"vault.stock-note.update": {
|
|
1203
|
+
method: "POST",
|
|
1204
|
+
path: "/application/open-vault/stock-pool/updateNote",
|
|
1205
|
+
kind: "json",
|
|
1206
|
+
description: "Replace the content of a stock note",
|
|
1207
|
+
},
|
|
1208
|
+
"vault.stock-note.delete": {
|
|
1209
|
+
method: "POST",
|
|
1210
|
+
path: "/application/open-vault/stock-pool/deleteNote",
|
|
1211
|
+
kind: "json",
|
|
1212
|
+
destructive: { warning: "删除笔记会删掉整条笔记(含它与全部关联证券的关系),不可恢复;先用 'gangtise vault stock-note-list --note-id <id>' 核对内容。" },
|
|
1213
|
+
itemFailures: true,
|
|
1214
|
+
description: "Delete stock notes",
|
|
1215
|
+
},
|
|
1154
1216
|
"alternative.edb-search": {
|
|
1155
1217
|
method: "POST",
|
|
1156
1218
|
path: "/application/open-alternative/EDB/search",
|
package/dist/src/core/errors.js
CHANGED
|
@@ -48,16 +48,16 @@ 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 取值是否合法。",
|
|
58
|
-
"130003": "资源未生成或该条记录未附带文件——换一条有附件的记录(列表里 hasAttachment/hasFile 为 true
|
|
57
|
+
"130002": "确认 ID 有效且本账号可见(云盘的文件 / 文件夹 ID 用 `vault drive-folder-list` 核对,笔记 ID 用 `vault stock-note-list` 核对;删除类命令收到此码可能是目标已被删掉);下载类还需检查 --file-type 取值是否合法。",
|
|
58
|
+
"130003": "资源未生成或该条记录未附带文件——换一条有附件的记录(列表里 hasAttachment/hasFile 为 true 的),或稍后再试;`vault wechat-message-download` 收到此码是消息不存在、本账号无权访问或文件已不可用,重试不会变。",
|
|
59
59
|
"130004": "下载 ID 需为数字,检查该命令的 --*-id 参数是否传对。",
|
|
60
|
-
"130005": "对照命令 --help 检查 --file-type / --content-type
|
|
60
|
+
"130005": "对照命令 --help 检查 --file-type / --content-type 取值;`vault wechat-message-download` 收到此码是该消息为 text / url、没有文件——只有 document / image / audio / video 消息可下载。",
|
|
61
61
|
"140001": "稍后用对应 *-check 命令查询。",
|
|
62
62
|
"140002": "终态失败,立即重试同一请求不会变。msg 指向参数(EDE 的指标参数名/枚举以 indicator search 的 parameterList 为准)就改参数重新提交;msg 是「业务处理失败」则与参数无关,稍后再试,持续出现请带 trace 报障。",
|
|
63
63
|
"210001": "换一篇,或改用 list 取正文摘要。",
|
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:
|
|
133
|
+
match: new RegExp(`不支持参数\\s*(?:tradeDate|reportDate)|${missing("tradeDate|reportDate").source}`),
|
|
120
134
|
when: (message) => distinctFailureShapes(message) > 1,
|
|
121
|
-
hint: "🔴 **这条报错涉及多个指标,而且它们失败的方式不同**——同一个改法套不到全部,按 msg 里每个指标各自说的话分别处理:msg 里对某个指标说「不支持参数 X;
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
},
|
|
@@ -3,6 +3,7 @@ import { normalizeRows } from "./normalize.js";
|
|
|
3
3
|
import { PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
|
|
4
4
|
const PRICE_TOLERANCE = 0.0005;
|
|
5
5
|
const agrees = (close, price) => price > 0 && Math.abs(close - price) / price <= PRICE_TOLERANCE;
|
|
6
|
+
const CHANGE_TOLERANCE = 0.005;
|
|
6
7
|
const QUOTABLE = /^(\d{6}\.(SH|SZ|BJ)|\d{5}\.HK|[A-Z][A-Z0-9.]*\.(O|N|A))$/;
|
|
7
8
|
const QUOTE_BATCH = 500;
|
|
8
9
|
const CLOSES_WINDOW_DAYS = 10;
|
|
@@ -31,7 +32,9 @@ async function candidatesFor(name, lookup) {
|
|
|
31
32
|
const bySuffix = (await Promise.all(["O", "N"].map((suffix) => lookup.search(`${key}.${suffix}`))))
|
|
32
33
|
.flatMap((rows) => toCandidates(rows))
|
|
33
34
|
.filter((c) => ticker(c.code) === key);
|
|
34
|
-
|
|
35
|
+
for (const c of bySuffix)
|
|
36
|
+
if (!exact.some((e) => e.code === c.code))
|
|
37
|
+
exact.push(c);
|
|
35
38
|
}
|
|
36
39
|
const prefix = base(key).length >= 2 ? found.filter((c) => !exact.includes(c) && base(c.name).startsWith(base(key))) : [];
|
|
37
40
|
return [...exact, ...prefix.map((c) => ({ ...c, prefix: true }))];
|
|
@@ -60,7 +63,7 @@ function byPrice(row, candidates, prices, etf, unchecked) {
|
|
|
60
63
|
return undefined;
|
|
61
64
|
const matched = tier.filter((c) => prices.get(c.code)?.some((p) => agrees(close, p)));
|
|
62
65
|
if (matched.length)
|
|
63
|
-
return
|
|
66
|
+
return matched;
|
|
64
67
|
}
|
|
65
68
|
return undefined;
|
|
66
69
|
}
|
|
@@ -124,59 +127,133 @@ export async function fillComponentCodes(rows, lookup) {
|
|
|
124
127
|
const pending = rows.filter(needsCode);
|
|
125
128
|
const names = [...new Set(pending.map((r) => r.componentName))];
|
|
126
129
|
const candidates = new Map();
|
|
130
|
+
const failed = new Set();
|
|
127
131
|
await runWithConcurrency(names, PAGE_CONCURRENCY, async (name) => {
|
|
128
|
-
|
|
132
|
+
try {
|
|
133
|
+
candidates.set(name, await candidatesFor(name, lookup));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
candidates.set(name, []);
|
|
137
|
+
failed.add(name);
|
|
138
|
+
}
|
|
129
139
|
});
|
|
130
140
|
const quotable = [...new Set([...candidates.values()].flat().filter((c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code)).map((c) => c.code))];
|
|
131
141
|
const prices = new Map((await runWithConcurrency(inBatches(quotable, QUOTE_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.quotes(codes)))).flatMap((quoted) => [...quoted]));
|
|
132
142
|
const isQuotable = (c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code);
|
|
143
|
+
const candidatesOf = (row) => candidates.get(row.componentName) ?? [];
|
|
144
|
+
const marketsOf = (listed) => new Set(listed.map((c) => market(c.code)));
|
|
133
145
|
const codes = new Map();
|
|
146
|
+
const tentative = new Map();
|
|
134
147
|
const etfs = new Map();
|
|
135
148
|
const place = (row, code, etf) => {
|
|
136
149
|
codes.set(row, code);
|
|
150
|
+
tentative.delete(row);
|
|
137
151
|
if (code)
|
|
138
152
|
etf.used.add(code);
|
|
139
153
|
};
|
|
154
|
+
const settle = (row, matched, etf) => {
|
|
155
|
+
if (marketsOf(matched).size > 1)
|
|
156
|
+
tentative.set(row, matched);
|
|
157
|
+
else
|
|
158
|
+
place(row, narrow(matched, etf), etf);
|
|
159
|
+
};
|
|
160
|
+
const unverified = new Map();
|
|
140
161
|
for (const fundCode of new Set(pending.map((r) => r.fundCode))) {
|
|
141
162
|
const own = pending.filter((r) => r.fundCode === fundCode);
|
|
142
163
|
const etf = { exchange: exchange(String(fundCode)), used: new Set() };
|
|
143
164
|
etfs.set(fundCode, { own, etf });
|
|
144
165
|
for (const row of own) {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
|
|
166
|
+
const matched = byPrice(row, candidatesOf(row), prices, etf, isQuotable);
|
|
167
|
+
if (!matched)
|
|
168
|
+
continue;
|
|
169
|
+
const held = marketsOf(matched);
|
|
170
|
+
if (held.size === 1 && candidatesOf(row).some((c) => isQuotable(c) && !held.has(market(c.code))))
|
|
171
|
+
unverified.set(row, matched);
|
|
172
|
+
else
|
|
173
|
+
settle(row, matched, etf);
|
|
148
174
|
}
|
|
149
175
|
}
|
|
150
|
-
const open = (own) => own.filter((r) => !codes.has(r) && typeof r.close === "number" && (
|
|
176
|
+
const open = (own) => own.filter((r) => !codes.has(r) && typeof r.close === "number" && candidatesOf(r).some(isQuotable));
|
|
151
177
|
const lagging = [...etfs.values()].filter(({ own }) => open(own).length);
|
|
178
|
+
const history = new Map();
|
|
152
179
|
if (lagging.length && lookup.closes) {
|
|
153
|
-
const wanted = [...new Set(lagging.flatMap(({ own }) => own.flatMap((r) => (
|
|
180
|
+
const wanted = [...new Set(lagging.flatMap(({ own }) => own.flatMap((r) => candidatesOf(r).filter(isQuotable).map((c) => c.code))))];
|
|
154
181
|
const end = beijingDateString(new Date());
|
|
155
182
|
const start = beijingDateString(new Date(Date.now() - CLOSES_WINDOW_DAYS * 86_400_000));
|
|
156
|
-
const
|
|
183
|
+
for (const part of await runWithConcurrency(inBatches(wanted, CLOSES_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.closes(codes, start, end)))) {
|
|
184
|
+
for (const [code, series] of part)
|
|
185
|
+
history.set(code, series);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const mayBe = (row, c) => !history.has(c.code) || history.get(c.code).some(([, close]) => agrees(row.close, close));
|
|
189
|
+
for (const { own, etf } of etfs.values()) {
|
|
190
|
+
for (const row of own) {
|
|
191
|
+
const matched = unverified.get(row);
|
|
192
|
+
if (!matched)
|
|
193
|
+
continue;
|
|
194
|
+
const held = marketsOf(matched);
|
|
195
|
+
const contested = lookup.closes !== undefined && candidatesOf(row).some((c) => isQuotable(c) && !etf.used.has(c.code) && !held.has(market(c.code)) && mayBe(row, c));
|
|
196
|
+
if (!contested && matched.every((c) => !etf.used.has(c.code)))
|
|
197
|
+
settle(row, matched, etf);
|
|
198
|
+
else
|
|
199
|
+
tentative.set(row, matched);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (lagging.length && lookup.closes) {
|
|
157
203
|
for (const { own, etf } of lagging) {
|
|
158
204
|
const dates = listDates(own, candidates, history);
|
|
159
205
|
const onDate = closesOn(dates, history);
|
|
160
206
|
for (const row of open(own)) {
|
|
161
|
-
const unchecked = (c) => isQuotable(c) && (
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
|
|
207
|
+
const unchecked = (c) => isQuotable(c) && mayBe(row, c);
|
|
208
|
+
const matched = byPrice(row, candidatesOf(row), onDate, etf, unchecked);
|
|
209
|
+
if (matched)
|
|
210
|
+
settle(row, matched, etf);
|
|
165
211
|
}
|
|
166
212
|
}
|
|
167
213
|
}
|
|
214
|
+
for (const { own, etf } of etfs.values()) {
|
|
215
|
+
for (const row of own) {
|
|
216
|
+
const change = row.pctChange;
|
|
217
|
+
if (codes.has(row) || typeof change !== "number" || typeof row.close !== "number")
|
|
218
|
+
continue;
|
|
219
|
+
const close = row.close;
|
|
220
|
+
const pool = candidatesOf(row).filter((c) => !etf.used.has(c.code));
|
|
221
|
+
if (!pool.length || !pool.every((c) => isQuotable(c) && history.has(c.code)))
|
|
222
|
+
continue;
|
|
223
|
+
const atClose = (c) => history.get(c.code).filter(([, price]) => agrees(close, price));
|
|
224
|
+
const hit = pool.filter((c) => atClose(c).some(([, , moved]) => moved !== undefined && Math.abs(moved - change) <= CHANGE_TOLERANCE));
|
|
225
|
+
const unknown = pool.some((c) => !hit.includes(c) && atClose(c).some(([, , moved]) => moved === undefined));
|
|
226
|
+
if (!hit.length || unknown || marketsOf(hit).size > 1)
|
|
227
|
+
continue;
|
|
228
|
+
const code = narrow(hit, etf);
|
|
229
|
+
if (code)
|
|
230
|
+
place(row, code, etf);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
168
233
|
for (const { own, etf } of etfs.values()) {
|
|
169
234
|
const markets = new Set([...etf.used].map(market));
|
|
235
|
+
let undecided = own.some((r) => failed.has(r.componentName));
|
|
170
236
|
for (const r of own) {
|
|
171
|
-
|
|
172
|
-
|
|
237
|
+
if (codes.get(r))
|
|
238
|
+
continue;
|
|
239
|
+
const held = marketsOf(candidatesOf(r).filter((c) => !etf.used.has(c.code) && (typeof r.close !== "number" || mayBe(r, c))));
|
|
240
|
+
if (held.size > 1)
|
|
241
|
+
undecided = true;
|
|
242
|
+
else if (held.size === 1)
|
|
173
243
|
markets.add([...held][0]);
|
|
174
244
|
}
|
|
175
|
-
if (markets.size === 1)
|
|
245
|
+
if (!undecided && markets.size === 1)
|
|
176
246
|
etf.market = [...markets][0];
|
|
247
|
+
for (const row of own) {
|
|
248
|
+
const matched = tentative.get(row);
|
|
249
|
+
if (!matched)
|
|
250
|
+
continue;
|
|
251
|
+
const pool = matched.filter((c) => !etf.used.has(c.code) && market(c.code) === etf.market);
|
|
252
|
+
place(row, pool.length ? narrow(pool, etf) : null, etf);
|
|
253
|
+
}
|
|
177
254
|
for (const row of own)
|
|
178
255
|
if (!codes.has(row))
|
|
179
|
-
place(row, byName(
|
|
256
|
+
place(row, byName(candidatesOf(row), etf), etf);
|
|
180
257
|
}
|
|
181
258
|
const unresolved = [];
|
|
182
259
|
const filled = rows.map((row) => {
|
|
@@ -272,14 +349,14 @@ export function clientLookup(client) {
|
|
|
272
349
|
return Array.isArray(data?.list) ? data.list : [];
|
|
273
350
|
},
|
|
274
351
|
async closes(codes, startDate, endDate) {
|
|
275
|
-
const data = normalizeRows(await client.call("quote.day-kline", { securityList: codes, startDate, endDate, fieldList: ["securityCode", "tradeDate", "close"], limit: 10_000 }));
|
|
352
|
+
const data = normalizeRows(await client.call("quote.day-kline", { securityList: codes, startDate, endDate, fieldList: ["securityCode", "tradeDate", "close", "preClose"], limit: 10_000 }));
|
|
276
353
|
const list = Array.isArray(data) ? data : (data?.list ?? []);
|
|
277
354
|
const out = new Map();
|
|
278
355
|
for (const k of list) {
|
|
279
356
|
if (typeof k.securityCode !== "string" || typeof k.tradeDate !== "string" || typeof k.close !== "number")
|
|
280
357
|
continue;
|
|
281
358
|
const series = out.get(k.securityCode) ?? [];
|
|
282
|
-
series.push([k.tradeDate, k.close]);
|
|
359
|
+
series.push(typeof k.preClose === "number" && k.preClose > 0 ? [k.tradeDate, k.close, (k.close / k.preClose - 1) * 100] : [k.tradeDate, k.close]);
|
|
283
360
|
out.set(k.securityCode, series);
|
|
284
361
|
}
|
|
285
362
|
for (const series of out.values())
|
|
@@ -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`);
|