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.
@@ -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",
@@ -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
- 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
  },
@@ -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
- exact.push(...bySuffix);
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 narrow(matched, etf);
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
- candidates.set(name, await candidatesFor(name, lookup).catch(() => []));
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 code = byPrice(row, candidates.get(row.componentName) ?? [], prices, etf, isQuotable);
146
- if (code !== undefined)
147
- place(row, code, etf);
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" && (candidates.get(r.componentName) ?? []).some(isQuotable));
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) => (candidates.get(r.componentName) ?? []).filter(isQuotable).map((c) => c.code))))];
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 history = new Map((await runWithConcurrency(inBatches(wanted, CLOSES_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.closes(codes, start, end)))).flatMap((part) => [...part]));
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) && (history.get(c.code)?.some(([, close]) => agrees(row.close, close)) ?? true);
162
- const code = byPrice(row, candidates.get(row.componentName) ?? [], onDate, etf, unchecked);
163
- if (code !== undefined)
164
- place(row, code, etf);
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
- const held = new Set((candidates.get(r.componentName) ?? []).filter((c) => !c.prefix).map((c) => market(c.code)));
172
- if (held.size === 1)
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(candidates.get(row.componentName) ?? [], etf), etf);
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`);