gangtise-openapi-cli 0.38.0 → 0.39.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 CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
+ - **v0.39.0 — 2026-09-12**:① **导出文件可核验**:`csv` / `jsonl` 的 `<文件>.meta.json` 增加数据文件的字节数 `bytes` 与内容哈希 `sha256`,转交或归档前 `shasum -a 256 <文件>` 比一下,就能确认这份元信息描述的是旁边这份数据。② **并发导出到同一 `--output` 不再互相掺混**:两个进程同时写同一路径时各写各的暂存文件,最终文件是其中某一次的完整产物(此前可能把两次的行混进一个文件、还报成功);收尾时回读比对哈希,文件已被另一次导出替换就在 stderr 说明并**退出码 4**——数据是完整的,只是那个位置上的文件不是本次产物(与退出码 3 同时发生时退出 3)。并发导出请给各自不同的 `--output`。③ **K 线分片合并的缺列护栏**:合并结果的列集取自第一个有数据的分片,只有靠后分片才返回的列放不下——现在标 `partial` + `droppedColumns`、退出码 3 并在 stderr 点名(此前静默丢弃),需要这些列就缩小日期区间单独拉。④ **`fundamental valuation-analysis` 的 `--skip-null` 与 `--field` 同用**:`--skip-null` 判的是 `value` 与 `percentileRank` 两列,`--field` 没点名它们时此前会把每一行都判成空、返回 `{total: 0, list: []}`(读起来像「这只票没有估值历史」);现在自动补取这两列做过滤,输出仍只给 `--field` 点名的列。⑤ `ai stock-summary` 单次上限由 5000 放宽到接口上限 **6000**,全 A 股可一次提交完。⑥ 文档:EDE 指标覆盖与「分钟线加总对日线」的口径按当前实测更新——`finc_pe_ttm` / `finc_pb_mrq` 港股均有数;Σ分钟 `volume` 与日线 `volume` 精确相等,Σ分钟 `amount` 与日线只差浮点尾差。
9
10
  - **v0.38.0 — 2026-09-06**:① `quote realtime` / `day-kline` / `minute-kline` 支持**沪深 ETF**(`512800.SH`)与 **20 个全球指数**(`SPX.SPI` 标普500 / `N225.NKI` 日经225 / `HSI.HI` 恒生…,清单见 `gangtise-openapi/references/commands/quote.md`),代码直接传即可;全市场关键字 `aShares` 不含 ETF;全球指数 realtime 的 `volume` / `amount` / `amplitude` 与分钟 K 的 `volume` / `amount` 为 `null`,日 K 只有 `amount` 为 `null`;`tradeTime` 是交易所当地时间。② `quote realtime` 字段集:新增 `tradeStatus`(仅 A 股 / 港股个股有值),`turnoverRate` / `volumeRatio` 不返回,美股 `amount` 为 `null`。③ **缺列护栏**:`quote` 系带 `--field` 时,请求了但服务端没回的列(字段名写错或已下线,服务端不报错)标 `partial` + `missingFields`、退出码 3 并在 stderr 点名;`--field` 只回点名的列、不自动附带身份列(日 K 要自己写进 `securityCode` / `tradeDate`,分钟 K 是 `securityCode` / `tradeTime`,realtime 是 `securityCode`;`fund-flow` 会自动附带)。④ **列结构护栏**:K 线全市场分片合并按列名对齐各片;响应的 `fieldList` 有重名列、或数组行没有 `fieldList` 时报错退出 1,不按位置拍平;`quote` 单请求收到无 `list` 的载荷报错退出 1;全量翻页从末页起步时同样做 `total` 封顶探测;后续页 / 分片自带的 `partial` 保留到合并结果。⑤ `fundamental earning-forecast` 的 `roe` 单位为百分比(`35.6` = 35.6%)。⑥ ETF 有复权因子:day-kline `adjustFactor` 与 EDE `qte_adj_factor` 都覆盖。⑦ 文档:`SKILL.md` 精简为规则 + 路由 + 引用,错误码全表、不报错的坑、`screener` 缺列判据与困境自救集中在 `references/errors.md`。⑧ `quote minute-kline` 的 `--security` 可重复,逐只并发请求后按传入顺序合并;`quote day-kline` 显式多证券在「证券数 × 交易日数」超过 `--limit` 时自动逐只请求合并,撞上限的证券标 `partial` + `truncatedSecurities`。⑨ **大导出**:`--format jsonl` / `csv` 加 `--output` 时,翻页 / 分片 / 逐只结果按到达顺序逐批写盘,内存不随行数增长(csv 磁盘两遍);落盘时旁边生成 `<文件>.meta.json`(命令、数据行数、列、`complete` 与 `partial` 等完整性标记、抓取时间与时区、CLI 版本;密钥类字段脱敏),文件转交后仍可核验。⑩ `ai stock-summary` 单次最多 5000 只(更大的批次服务端返回空列表,CLI 本地拦截并提示分批)。⑪ A 股公告与 `ai knowledge-batch` 的 `--start-time` / `--end-time` 按**北京时间**换算成毫秒,与运行机器的时区无关。⑫ 省略 `--output` 的自动命名下载并发时各得其名、不互相覆盖;`raw call auth.login` 不需要环境里先有凭证。
10
11
  - **v0.37.1 — 2026-08-31**:**文档修正,无代码变更**。① `indicator screener` 用静态属性选股的示例补进 README——`pty_*`(经营范围·注册地…)/ `scr_*`(上市板块·ISIN…)两族用 `--indicator-param "F1:"`(冒号后留空)声明该指标不吃查询日期后,可直接用于条件选股。② **数据权限时间范围的说明按实测更正**:这个窗口按**账号**配、不按接口配——`indicator` 的 `cross-section` / `time-series` / `screener` 与 `quote day-kline` 在同一条边界上,**撞到 `110003` 换接口绕不过去**,正确做法是把日期移进权限范围,或联系客户经理开通更长历史。③ EDE 文本筛选示例换成有区分度的条件,避免示例条件把整个板块原样返回、看不出筛选是否生效。
11
12
  - **v0.37.0 — 2026-08-29**:🔴 **下载的「智能文件命名」改为默认只读缓存**。省略 `--output` 时仍优先用 `title-cache` 里的真实标题——先 `list` 再 `download` 的常规用法**不受影响,也不产生额外调用**;但**缓存未命中时不再自动回查 list 接口**,改为退回服务端返回的文件名或 `<type>-<id>.<ext>`。回查一次要拉 200 条记录(4 次请求),而这 12 个下载命令里有 9 个的 list 按 0.1 积分/条计费,约 20 积分——这笔开销只用于取一个更易读的文件名,所以改成显式的 `--resolve-title`。加了该参数时,取回的 200 条标题会一并写入缓存,同一批后续下载不再重复回查。⚠️ **依赖旧行为拿中文文件名的脚本**:升级后会得到 ID 文件名,补 `--resolve-title`,或按推荐用法先跑一次 `list`。**另有三项修正**:① 下载已成功、仅标题回查阶段遇到异常响应时,退出码会变成 3(脚本按 `!= 0` 判失败会误判为下载失败),现已隔离——下载完整就是 0;② `indicator cross-section` / `time-series` 的 `--indicator-param` 若写了 `--indicator` 里没有的指标编码(多为拼写错误),发请求前直接报错并指出是哪个编码(与 `screener` 一致);③ 下载请求的超时改为与其他请求同一套解析逻辑,行为对齐。
12
13
  - **v0.36.0 — 2026-08-18**:**日期写法放宽**——`YYYY-MM-DD`、`YYYY/MM/DD`、`YYYYMMDD` 三种「年在前」写法都收,统一归一成 `YYYY-MM-DD` 发出(datetime 只归一日期部分,Unix 时间戳原样透传);「年在后」写法(`01-07-2026`)仍在本地拒绝——接口会按美式「月在前」解析它,欧洲习惯写法会静默拿到差半年的数据,详见「关于日期格式」。**`indicator screener` 支持无日期指标**:`--indicator-param "F1:"`(冒号后留空)声明该指标不要查询日期,`pty_*` / `scr_*` 静态属性两族与 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg` 可以直接用于条件选股,写法与截面一致、可与真实参数共存。**全量拉取的 `total` 封顶探测覆盖 `ai hot-topic`**。另:EDE 报错提示同步更新;补充计费说明(`ai hot-topic` 50/篇 的「篇」= 一整份报告;按篇/按条计费的接口查不到内容不扣分)。
13
- - **v0.35.0 — 2026-08-16**:新增 `--indicator-param "<code>:"`(冒号后留空)声明「这个指标不要查询日期」,用于 `parameterList` 里没有日期参数的指标——`pty_*`(经营范围/注册地/法定代表人…)、`scr_*`(上市市场/上市板块/ISIN…)两族,以及 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg`。这类指标不收 `--date` 注入的 `tradeDate`,需要用该写法声明;它可与真实参数共存(`"code:" + "code:fiscalYear=2025"`)。**EDE 日期参数报错提示重写**:拆成五种报文形态分别给建议,服务端同时点名「不该有的键」和「缺的键」时直说换哪个,只说了一半时不再瞎猜,多指标批量报错时不再用单数口吻指向其中一个指标。另:`--rank-type` 的说明按实测更正(差别大小取决于关键词,`--search-type` 不影响 `--rank-type 1` 取回哪些条目);`ai hot-topic` 全量拉取的 `total` 封顶探测在 v0.36.0 起恢复(该探测不额外计费)。
14
14
 
15
15
  ### 历史里程碑
16
16
 
@@ -189,7 +189,7 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
189
189
  | **AI** | `knowledge-batch` | 知识库批量检索 |
190
190
  | | `knowledge-resource-download` | 知识资源下载 |
191
191
  | | `security-clue` | 个股线索 |
192
- | | `stock-summary` | 个股看点(精炼投研总结,按代码批量、单次最多 5000 个——超过时服务端整批返空,CLI 本地拦截并提示分批;仅 A 股/港股,不支持全市场) |
192
+ | | `stock-summary` | 个股看点(精炼投研总结,按代码批量、单次最多 6000 个;仅 A 股/港股,不支持全市场) |
193
193
  | | `one-pager` | 一页通 |
194
194
  | | `investment-logic` | 投资逻辑 |
195
195
  | | `peer-comparison` | 同业对比 |
@@ -262,7 +262,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
262
262
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
263
263
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
264
264
  - **流式输出**:`--format jsonl` 或 `csv` 加 `--output <file>` 时,翻页 / 全市场分片 / 逐只请求的行**按到达顺序逐批写盘**,取数阶段不再持有整份结果,内存不随行数增长(80 万行导出常驻约 150 MB);csv 先落临时行文件、收尾时按列并集写表头再转成 csv(磁盘两遍、内存不变)。不足 1000 行的结果仍走原来的整体写出,文件字节与之前一致。任一只 / 页 / 片失败,命令退出时后台已无取数与写盘,不留 `.part`。
265
- - **导出元信息**:`csv` / `jsonl` 落盘时旁边生成 `<文件>.meta.json`——命令行、数据行数、列名、`complete`(与退出码一致:退出 3 即 `false`)、`total` / `partial` / `failedPages` / `failedShards` / `truncatedShards` / `missingFields` 等全部完整性标记、抓取时间与时区、CLI 版本。命令行与结果里的 key / secret / token 类字段写成 `[redacted]`。元信息与数据文件同一代发布,不会出现新数据配旧元信息。这两种格式的文件本身只有数据行,转交之后靠它核验是否完整;`json` 自带标记,不生成。
265
+ - **导出元信息**:`csv` / `jsonl` 落盘时旁边生成 `<文件>.meta.json`——命令行、数据行数、列名、`complete`(与退出码一致:退出 3 即 `false`)、`total` / `partial` / `failedPages` / `failedShards` / `truncatedShards` / `droppedColumns` / `missingFields` 等全部完整性标记、抓取时间与时区、CLI 版本,以及数据文件的字节数 `bytes` 与内容哈希 `sha256`。命令行与结果里的 key / secret / token 类字段写成 `[redacted]`。元信息在数据文件发布之后才就位,导出失败不会留下新数据配旧元信息。**转交或归档前建议核一次**:`shasum -a 256 <文件>` 与元信息里的 `sha256` 相等,才说明这份元信息描述的就是旁边这份数据——两个进程同时导出到**同一个 `--output`** 时,最终文件一定是其中某一次的完整产物,但旁边的元信息有可能来自另一次,核哈希就能发现。**输的那一次自己也会报**:收尾时回读一次 `--output`,与本次写出的哈希不符就在 stderr 说明「这个文件不是本次命令的产物」并**退出码 4**。这两种格式的文件本身只有数据行,转交之后靠它核验是否完整;`json` 自带标记,不生成。
266
266
  - **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档端点例外**(one-pager 等生成/提交类 + `tool file-parse` 提交 + 50/篇 的 summary / foreign-report / my-conference 下载 + 单价未公布但保守同档的 pamirs-summary 下载,共 18 个):5xx/超时不重放——**重放会重复扣分**:服务端可能已经执行并计费,重发按次计费的再扣一次,重发按篇/按条计费的会把已交付的行再计一次;仅连接失败、429 与 token 自愈重试。**`indicator`(EDE)端点对 `999999` 不重试**——重放一次已计费的查询没有意义(EDE 无数据不用此码,而是保留行列的占位单元格 `null`;空表另表示整轴 code 未识别或参数名写错)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
267
267
 
268
268
  <!-- no-replay-endpoints
@@ -325,8 +325,8 @@ vault.my-conference.download
325
325
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
326
326
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
327
327
  - 安全上限:自动翻页最多 1000 页,防止异常循环
328
- - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`quote` 系带 `--field` 而服务端没回的列为 `missingFields`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
329
- - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。这个分支很少触发——服务端对解析不了的代码直接报 `100003` 并点名是哪个(指标码拼错 →「指标 xxx 不存在」;证券后缀错,如美股写成 `AAPL.US` 而非 `AAPL.O` →「xxx 不是有效证券或者板块ID」),**无论同批有没有正确的代码都会报**,CLI 相应退出 1。真实的无数据/无覆盖仍是占位单元格 + 退出码 0。占位值统一是 `null`。⚠️ **报告期类指标(`is_*`)的时序上大部分行都是占位**(只有报告期末那几行是真值),`null` 虽被 Excel / pandas / SQL 的聚合跳过,**但行数不变**,手工「总和 ÷ 行数」仍会差几十倍;详见 skill 的 `references/commands/indicator.md`。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。⚠️ 这一档以「服务端返回了命中行」为前提;**零命中时一律退出码 0**(没有行需要被质疑),所以空集不能直接当成「无标的符合条件」——另有两种成因产生**逐字相同**的输出:**日期没落在报告期末**(报告期类指标此时整批 `null`),或**该指标不覆盖这批证券**(如拿 A 股专属指标查港美股)。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整/`1` 硬失败
328
+ - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`,只有部分分片返回、合并时放不下的列为 `droppedColumns`;`quote` 系带 `--field` 而服务端没回的列为 `missingFields`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
329
+ - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。这个分支很少触发——服务端对解析不了的代码直接报 `100003` 并点名是哪个(指标码拼错 →「指标 xxx 不存在」;证券后缀错,如美股写成 `AAPL.US` 而非 `AAPL.O` →「xxx 不是有效证券或者板块ID」),**无论同批有没有正确的代码都会报**,CLI 相应退出 1。真实的无数据/无覆盖仍是占位单元格 + 退出码 0。占位值统一是 `null`。⚠️ **报告期类指标(`is_*`)的时序上大部分行都是占位**(只有报告期末那几行是真值),`null` 虽被 Excel / pandas / SQL 的聚合跳过,**但行数不变**,手工「总和 ÷ 行数」仍会差几十倍;详见 skill 的 `references/commands/indicator.md`。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。⚠️ 这一档以「服务端返回了命中行」为前提;**零命中时一律退出码 0**(没有行需要被质疑),所以空集不能直接当成「无标的符合条件」——另有两种成因产生**逐字相同**的输出:**日期没落在报告期末**(报告期类指标此时整批 `null`),或**该指标不覆盖这批证券**(如拿 A 股专属指标查港美股)。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整/`4` 数据写出完整、但 `--output` 指向的文件在收尾期间被另一次写向同一路径的导出替换掉了(不是本次命令的产物,stderr 会说明并给出两边的 sha256 前缀;与 `3` 同时发生时退出 `3`)/`1` 硬失败
330
330
  - **分页端点返回 `null` 也退出 3**:分页端点的正常响应是 `{total, list}`,真实的空结果是 `{total: 0, list: []}`。若响应体是 `null`,CLI 在 stderr 告警并**退出码 3**——只给告警的话,脚本无法区分「这个筛选确实没命中」和「这个筛选没生效」。机器格式(jsonl/csv)此时 **stdout 不输出任何字节**(不是空行),`--format json` 仍忠实打印 `null`。⚠️ 带 `--output` 时文件仍会被创建:csv 会写入 3 字节 UTF-8 BOM(Excel 兼容用),jsonl 为 0 字节(旁边的 `.meta.json` 标 `complete: false`)——**按文件大小判空的脚本要留意 csv 的这 3 个字节**。
331
331
  - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:分页端点的 `total` 若被服务端封顶(返回一个固定上限而非真实条数),省略 `--size` 的全量拉取会**正好取满那个上限就停、且不报任何异常**——导出的文件是截断的却看不出来。全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
332
332
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
@@ -554,7 +554,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
554
554
  gangtise ai knowledge-batch --query 新能源汽车 --resource-type 10 --resource-type 11 --top 10
555
555
  gangtise ai security-clue --start-time "2026-04-01 00:00:00" --end-time "2026-04-09 23:59:59" --query-mode byIndustry --gts-code 821035.SWI --source researchReport --source announcement
556
556
  gangtise ai one-pager --security-code 600519.SH
557
- # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 5000 个(更大的批次拆开分次调);不支持全市场关键字
557
+ # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 6000 个(更大的批次拆开分次调);不支持全市场关键字
558
558
  gangtise ai stock-summary --security 600519.SH --security 00700.HK --format json
559
559
  gangtise ai investment-logic --security-code 600519.SH
560
560
  gangtise ai peer-comparison --security-code 600519.SH
package/dist/src/cli.js CHANGED
@@ -518,7 +518,10 @@ addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3
518
518
  fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).addOption(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product")).option("--period <type>", "Period: interim/annual", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) })));
519
519
  fundamental.command("valuation-analysis").requiredOption("--security-code <code>").addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).makeOptionMandatory()).option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--limit <number>").option("--field <field>", "Field", collectList, []).option("--skip-null", "Drop rows where value or percentileRank is null").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
520
520
  const format = parseOutputFormat(options.format);
521
- let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) });
521
+ const requested = maybeArray(options.field);
522
+ const filterFields = options.skipNull && requested ? ["value", "percentileRank"].filter((field) => !requested.includes(field)) : [];
523
+ const fieldList = requested && filterFields.length > 0 ? [...requested, ...filterFields] : requested;
524
+ let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList });
522
525
  if (options.skipNull) {
523
526
  const normalized = normalizeRows(data);
524
527
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
@@ -529,6 +532,13 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
529
532
  return false;
530
533
  const r = row;
531
534
  return r.value != null && r.percentileRank != null;
535
+ }).map((row) => {
536
+ if (filterFields.length === 0)
537
+ return row;
538
+ const r = { ...row };
539
+ for (const field of filterFields)
540
+ delete r[field];
541
+ return r;
532
542
  });
533
543
  data = { ...rec, list: filtered, total: filtered.length };
534
544
  }
@@ -634,12 +644,12 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
634
644
  }
635
645
  }));
636
646
  ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient((client) => checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output)));
637
- const STOCK_SUMMARY_MAX_SECURITIES = 5000;
638
- ai.command("stock-summary").description("Stock highlights: refined research summary per security (A-share / HK)").option("--security <code>", `Security code (e.g. 600519.SH / 00700.HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call (larger batches are answered with an empty list by the server); market keywords are NOT supported by this endpoint`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
647
+ const STOCK_SUMMARY_MAX_SECURITIES = 6000;
648
+ ai.command("stock-summary").description("Stock highlights: refined research summary per security (A-share / HK)").option("--security <code>", `Security code (e.g. 600519.SH / 00700.HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call; market keywords are NOT supported by this endpoint`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
639
649
  if (!options.security.length)
640
650
  throw new ValidationError(`--security is required: pass one or more security codes (A-share / HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call`);
641
651
  if (options.security.length > STOCK_SUMMARY_MAX_SECURITIES) {
642
- throw new ValidationError(`ai stock-summary: ${options.security.length} securities in one call — the server answers batches above about 5040 with an empty list (HTTP 200, no error), so the CLI stops at ${STOCK_SUMMARY_MAX_SECURITIES}. Split the codes into batches of at most ${STOCK_SUMMARY_MAX_SECURITIES} and run one call per batch.`);
652
+ throw new ValidationError(`ai stock-summary: ${options.security.length} securities in one call — the endpoint takes at most ${STOCK_SUMMARY_MAX_SECURITIES}. Split the codes into batches of at most ${STOCK_SUMMARY_MAX_SECURITIES} and run one call per batch.`);
643
653
  }
644
654
  checkMarketKeywords(options.security, [], "ai stock-summary");
645
655
  return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
@@ -683,7 +693,7 @@ vault.command("record-list").option("--from <number>", "Starting offset", "0").o
683
693
  addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-download", idOption: "--record-id", idField: "recordId", fallbackPrefix: "record", contentTypeDescription: "Content type: original/asr/summary", titleListEndpoint: "vault.record.list" });
684
694
  vault.command("my-conference-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--research-area <id>", "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--source <number>", "Recording source: 1=企微会议助理 2=会议服务微信群 (repeat)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category), sourceList: options.source.length ? options.source : undefined }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
685
695
  addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "my-conference-download", idOption: "--conference-id", idField: "conferenceId", fallbackPrefix: "conference", contentTypeDescription: "Content type: asr/summary", titleListEndpoint: "vault.my-conference.list" });
686
- vault.command("wechat-message-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are ignored and silently return the unfiltered total", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
696
+ vault.command("wechat-message-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
687
697
  vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
688
698
  vault.command("stock-pool-list").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.list", {})));
689
699
  vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", buildStockPoolStocksBody(options))));
@@ -7,6 +7,7 @@ import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredent
7
7
  import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from "./errors.js";
8
8
  import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
+ import { stagingPath } from "./output.js";
10
11
  import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, withRetry } from "./transport.js";
11
12
  import { attachRowSink } from "./rowSink.js";
12
13
  const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
@@ -504,7 +505,7 @@ export class GangtiseClient {
504
505
  }
505
506
  if (options?.streamTo) {
506
507
  await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
507
- const partPath = `${options.streamTo}.part`;
508
+ const partPath = stagingPath(options.streamTo);
508
509
  try {
509
510
  await pipeline(response.body, createWriteStream(partPath));
510
511
  await fs.rename(partPath, options.streamTo);
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { dirname, extname } from "node:path";
3
3
  import { DownloadError } from "./errors.js";
4
- import { saveOutputIfNeeded } from "./output.js";
4
+ import { saveOutputIfNeeded, stagingPath } from "./output.js";
5
5
  import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
6
6
  function sanitizeFilename(name) {
7
7
  return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
@@ -139,7 +139,7 @@ async function downloadUrlTo(url, outputPath) {
139
139
  return "signed-url";
140
140
  }
141
141
  };
142
- const partPath = `${outputPath}.part`;
142
+ const partPath = stagingPath(outputPath);
143
143
  await withRetry(async () => {
144
144
  const startedAt = Date.now();
145
145
  const requestOptions = {
@@ -1,6 +1,26 @@
1
+ import { createHash, randomBytes } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import { ConfigError } from "./errors.js";
3
4
  const OUTPUT_FORMATS = ["table", "json", "jsonl", "csv", "markdown"];
5
+ export function stagingPath(target, suffix = "part") {
6
+ return `${target}.${randomBytes(4).toString("hex")}.${suffix}`;
7
+ }
8
+ export function digestBuffer(content) {
9
+ const buf = typeof content === "string" ? Buffer.from(content, "utf8") : content;
10
+ return { bytes: buf.byteLength, sha256: createHash("sha256").update(buf).digest("hex") };
11
+ }
12
+ export async function digestFile(filePath) {
13
+ const { createReadStream } = await import("node:fs");
14
+ const hash = createHash("sha256");
15
+ let bytes = 0;
16
+ const stream = createReadStream(filePath);
17
+ for await (const chunk of stream) {
18
+ const buf = chunk;
19
+ bytes += buf.byteLength;
20
+ hash.update(buf);
21
+ }
22
+ return { bytes, sha256: hash.digest("hex") };
23
+ }
4
24
  export function parseOutputFormat(value) {
5
25
  const format = value ?? "table";
6
26
  if (OUTPUT_FORMATS.includes(format)) {
@@ -128,24 +148,24 @@ export function renderOutput(value, format) {
128
148
  }
129
149
  export async function streamOutputToFile(value, format, outputPath) {
130
150
  if (format !== "jsonl" && format !== "csv")
131
- return false;
151
+ return null;
132
152
  const list = pickList(value);
133
153
  if (!list)
134
- return false;
154
+ return null;
135
155
  if (list.length < 1000)
136
- return false;
156
+ return null;
137
157
  let csvRows = [];
138
158
  let csvColumns = [];
139
159
  if (format === "csv") {
140
160
  csvRows = list.filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)));
141
161
  if (csvRows.length === 0)
142
- return false;
162
+ return null;
143
163
  csvColumns = Array.from(new Set(csvRows.flatMap((row) => Object.keys(row))));
144
164
  }
145
165
  const { dirname } = await import("node:path");
146
166
  const { createWriteStream } = await import("node:fs");
147
167
  await fs.mkdir(dirname(outputPath), { recursive: true });
148
- const partPath = `${outputPath}.part`;
168
+ const partPath = stagingPath(outputPath);
149
169
  const stream = createWriteStream(partPath, { encoding: "utf8" });
150
170
  stream.on("error", () => { });
151
171
  try {
@@ -164,7 +184,9 @@ export async function streamOutputToFile(value, format, outputPath) {
164
184
  await new Promise((resolve, reject) => {
165
185
  stream.end((err) => err ? reject(err) : resolve());
166
186
  });
187
+ const digest = await digestFile(partPath);
167
188
  await fs.rename(partPath, outputPath);
189
+ return digest;
168
190
  }
169
191
  catch (error) {
170
192
  stream.destroy();
@@ -174,7 +196,6 @@ export async function streamOutputToFile(value, format, outputPath) {
174
196
  await fs.unlink(partPath).catch(() => { });
175
197
  throw error;
176
198
  }
177
- return true;
178
199
  }
179
200
  export function pickList(value) {
180
201
  if (Array.isArray(value))
@@ -213,7 +234,7 @@ export async function saveOutputIfNeeded(content, outputPath) {
213
234
  }
214
235
  const { dirname } = await import("node:path");
215
236
  await fs.mkdir(dirname(outputPath), { recursive: true });
216
- const partPath = `${outputPath}.part`;
237
+ const partPath = stagingPath(outputPath);
217
238
  try {
218
239
  if (typeof content === "string") {
219
240
  await fs.writeFile(partPath, content, "utf8");
@@ -221,7 +242,9 @@ export async function saveOutputIfNeeded(content, outputPath) {
221
242
  else {
222
243
  await fs.writeFile(partPath, content);
223
244
  }
245
+ const digest = digestBuffer(content);
224
246
  await fs.rename(partPath, outputPath);
247
+ return digest;
225
248
  }
226
249
  catch (error) {
227
250
  await fs.unlink(partPath).catch(() => { });
@@ -1,11 +1,21 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { normalizeRows } from "./normalize.js";
4
- import { countOutputRows, pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
4
+ import { countOutputRows, digestFile, pickList, renderOutput, saveOutputIfNeeded, stagingPath, streamOutputToFile } from "./output.js";
5
5
  import { getRowSink } from "./rowSink.js";
6
6
  import { extractTitles, writeTitleCache } from "./titleCache.js";
7
7
  import { CLI_VERSION } from "../version.js";
8
8
  const LARGE_RESULT_ROWS = 50_000;
9
+ export const SUPERSEDED_EXIT = 4;
10
+ export async function warnIfSuperseded(output, digest) {
11
+ const actual = await digestFile(output).catch(() => undefined);
12
+ if (!actual || actual.sha256 === digest.sha256)
13
+ return;
14
+ process.stderr.write(`[gangtise] warning: ${output} was replaced by another export to the same path while this one was finishing — the file there is not this command's output `
15
+ + `(wrote sha256 ${digest.sha256.slice(0, 12)}…, found ${actual.sha256.slice(0, 12)}…). Give concurrent exports distinct --output paths.\n`);
16
+ if (process.exitCode !== 3)
17
+ process.exitCode = SUPERSEDED_EXIT;
18
+ }
9
19
  function warnIfLargeInMemory(items, format) {
10
20
  if (items && items.length >= LARGE_RESULT_ROWS) {
11
21
  process.stderr.write(`[gangtise] note: ${items.length} rows in '${format}' is built entirely in memory; stream large exports to a file with --format jsonl --output <path> (or csv).\n`);
@@ -62,7 +72,7 @@ async function stageExportMeta(output, format, rows, columns, normalized, comple
62
72
  result: redactSecrets(result),
63
73
  };
64
74
  const metaPath = `${output}.meta.json`;
65
- const partPath = `${metaPath}.part`;
75
+ const partPath = stagingPath(metaPath);
66
76
  await fs.mkdir(path.dirname(output), { recursive: true });
67
77
  try {
68
78
  await fs.writeFile(partPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
@@ -72,8 +82,10 @@ async function stageExportMeta(output, format, rows, columns, normalized, comple
72
82
  throw error;
73
83
  }
74
84
  return {
75
- async commit() {
85
+ async commit(digest) {
76
86
  try {
87
+ if (digest)
88
+ await fs.writeFile(partPath, `${JSON.stringify({ ...doc, ...digest }, null, 2)}\n`, "utf8");
77
89
  await fs.rename(partPath, metaPath);
78
90
  }
79
91
  catch (error) {
@@ -118,21 +130,25 @@ export async function printData(data, format, output, cache) {
118
130
  const staged = format === "csv" || format === "jsonl"
119
131
  ? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, normalized, complete)
120
132
  : null;
133
+ let digest;
121
134
  try {
122
135
  if (streamed) {
123
136
  await streamed.finish();
137
+ digest = streamed.digest;
124
138
  }
125
- else if (!(await streamOutputToFile(normalized, format, output))) {
139
+ else if (!(digest = (await streamOutputToFile(normalized, format, output)) ?? undefined)) {
126
140
  warnIfLargeInMemory(items, format);
127
141
  const content = renderOutput(normalized, format);
128
- await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
142
+ digest = await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
129
143
  }
130
144
  }
131
145
  catch (error) {
132
146
  await staged?.discard();
133
147
  throw error;
134
148
  }
135
- await staged?.commit();
149
+ await staged?.commit(digest);
150
+ if (digest)
151
+ await warnIfSuperseded(output, digest);
136
152
  process.stdout.write(`${output}\n`);
137
153
  return;
138
154
  }
@@ -16,8 +16,10 @@ function formatDate(d) {
16
16
  return d.toISOString().slice(0, 10);
17
17
  }
18
18
  function columnRemap(header, shard) {
19
+ const headerNames = new Set(header.map(String));
20
+ const extra = shard.map(String).filter((field) => !headerNames.has(field));
19
21
  if (header.length === shard.length && header.every((field, i) => field === shard[i]))
20
- return null;
22
+ return { map: null, extra };
21
23
  const index = new Map(shard.map((field, i) => [String(field), i]));
22
24
  const map = [];
23
25
  for (const field of header) {
@@ -26,7 +28,7 @@ function columnRemap(header, shard) {
26
28
  return undefined;
27
29
  map.push(i);
28
30
  }
29
- return map;
31
+ return { map, extra };
30
32
  }
31
33
  export function isFullMarket(body, fullMarketValue) {
32
34
  const list = body.securityList;
@@ -126,6 +128,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
126
128
  const objectKeys = new Set();
127
129
  const truncatedShards = [];
128
130
  const partialShards = [];
131
+ const droppedColumns = new Set();
129
132
  const keep = async (rows) => {
130
133
  count += rows.length;
131
134
  if (!fieldList)
@@ -179,8 +182,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
179
182
  process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} returned columns that cannot be aligned with the first shard's fieldList; its rows were dropped (see failedShards)\n`);
180
183
  return;
181
184
  }
185
+ for (const field of remap?.extra ?? [])
186
+ droppedColumns.add(field);
182
187
  const rows = rec.list;
183
- await keep(remap ? rows.map((item) => (Array.isArray(item) ? remap.map((k) => item[k]) : item)) : rows);
188
+ const map = remap?.map;
189
+ await keep(map ? rows.map((item) => (Array.isArray(item) ? map.map((k) => item[k]) : item)) : rows);
184
190
  };
185
191
  await runInOrder(shards, config.concurrency ?? PAGE_CONCURRENCY, fetchShard, mergeShard);
186
192
  if (failedShards.length === shards.length) {
@@ -211,6 +217,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
211
217
  out.partial = true;
212
218
  process.stderr.write(`[gangtise] warning: ${partialShards.length}/${shards.length} shard(s) reported themselves partial; the merged result is marked partial\n`);
213
219
  }
220
+ if (droppedColumns.size > 0) {
221
+ out.partial = true;
222
+ out.droppedColumns = [...droppedColumns];
223
+ process.stderr.write(`[gangtise] warning: ${droppedColumns.size} column(s) came back only in later shards and could not be carried (see droppedColumns); the merged fieldList is the first data shard's — re-pull a narrower range to get them.\n`);
224
+ }
214
225
  if (truncatedShards.length > 0) {
215
226
  out.partial = true;
216
227
  out.truncatedShards = truncatedShards;
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { assertColumnarHeader, zipFieldRow } from "./normalize.js";
5
- import { csvEscape, formatScalar, writeLine } from "./output.js";
5
+ import { csvEscape, digestFile, formatScalar, stagingPath, writeLine } from "./output.js";
6
6
  import { extractTitles, MAX_TITLES_PER_ENDPOINT } from "./titleCache.js";
7
7
  export const ROW_SINK = Symbol("gangtise.rowSink");
8
8
  const STREAM_THRESHOLD = 1000;
@@ -11,6 +11,7 @@ export class ExportSink {
11
11
  format;
12
12
  cache;
13
13
  rows = 0;
14
+ digest;
14
15
  titles = {};
15
16
  titleCount = 0;
16
17
  buffer = [];
@@ -21,10 +22,14 @@ export class ExportSink {
21
22
  columns = [];
22
23
  columnSet = new Set();
23
24
  objectRows = 0;
25
+ partPath;
26
+ rowsPath;
24
27
  constructor(outputPath, format = "jsonl", cache) {
25
28
  this.outputPath = outputPath;
26
29
  this.format = format;
27
30
  this.cache = cache;
31
+ this.partPath = stagingPath(outputPath);
32
+ this.rowsPath = stagingPath(outputPath, "rows.part");
28
33
  }
29
34
  get opened() {
30
35
  return this.stream !== null;
@@ -37,12 +42,6 @@ export class ExportSink {
37
42
  return this.rows;
38
43
  return this.objectRows > 0 ? this.objectRows : this.rows;
39
44
  }
40
- get partPath() {
41
- return `${this.outputPath}.part`;
42
- }
43
- get rowsPath() {
44
- return `${this.outputPath}.rows.part`;
45
- }
46
45
  setFieldList(fields) {
47
46
  this.fields = fields;
48
47
  }
@@ -73,6 +72,7 @@ export class ExportSink {
73
72
  await endStream(stream);
74
73
  if (this.format === "csv")
75
74
  await this.assembleCsv();
75
+ this.digest = await digestFile(this.partPath);
76
76
  await fs.rename(this.partPath, this.outputPath);
77
77
  this.finished = true;
78
78
  }
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.38.0";
1
+ export const CLI_VERSION = "0.39.0";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.38.0"
3
+ version: "0.39.0"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
@@ -162,7 +162,7 @@ vault.my-conference.download
162
162
  | 跨类型语义搜索(研报+纪要+...) | `ai knowledge-batch`(多个 `--resource-type`) |
163
163
  | 知识库原文下载(搜到后取全文) | `ai knowledge-resource-download`(前置:`knowledge-batch` 拿 `resourceType`+`sourceId`;`250001`/旧 `433007`=组合不匹配) |
164
164
  | 一页通 / 投资逻辑 / 同业对比 / 调研提纲 | `ai one-pager / investment-logic / peer-comparison / research-outline` |
165
- | 个股看点 / 投研总结 / 公司速览 | `ai stock-summary`(`--security` **只收具体代码**,单次最多 5000 个、超过 CLI 本地拦截并提示分批;仅 A 股/港股,不支持 `aShares`/`hkStocks` 全市场批量) |
165
+ | 个股看点 / 投研总结 / 公司速览 | `ai stock-summary`(`--security` **只收具体代码**,单次最多 6000 个、超过时 CLI 报错并提示分批;仅 A 股/港股,不支持 `aShares`/`hkStocks` 全市场批量) |
166
166
  | 业绩点评(异步) | `ai earnings-review` |
167
167
  | 观点 PK / 多空辩论(异步) | `ai viewpoint-debate` |
168
168
  | 投研线索 | `ai security-clue`(前置:`reference securities-search` 拿 `gts-code`) |
@@ -296,7 +296,7 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
296
296
 
297
297
  ## 异常处理
298
298
 
299
- **退出码**:`0` 完整成功(含合法空结果)/ `3` 有数据但不完整(`partial: true`;stderr 有 warning,`--format json` 才看得见标记,table/csv/jsonl 只有数据行、看不出问题——csv/jsonl 落盘时看旁边 `<file>.meta.json` 的 `complete` 与 `result`。定位字段:页失败 `failedPages`、分片失败 `failedShards`、分片撞行数上限 `truncatedShards`、`total` 撞服务端上限 `totalCapped`、`--field` 请求了但没回的列 `missingFields`、逐只请求里撞行数上限的证券 `truncatedSecurities`、EDE 整轴没回 `omittedIndicators` / `omittedSecurities`)/ `1` 硬失败。**拿到 3 就必须告知用户缺了哪段,不能当成功静默继续。** 报错行带 `[trace <id>]`,**报障给 Gangtise 时务必带上**。
299
+ **退出码**:`0` 完整成功(含合法空结果)/ `3` 有数据但不完整(`partial: true`;stderr 有 warning,`--format json` 才看得见标记,table/csv/jsonl 只有数据行、看不出问题——csv/jsonl 落盘时看旁边 `<file>.meta.json` 的 `complete` 与 `result`;该文件还带 `bytes` / `sha256`,用 `shasum -a 256 <file>` 核一下就能确认这份元信息描述的是旁边这份数据。定位字段:页失败 `failedPages`、分片失败 `failedShards`、分片撞行数上限 `truncatedShards`、只有部分分片返回而合并时放不下的列 `droppedColumns`(缩小日期区间单独拉)、`total` 撞服务端上限 `totalCapped`、`--field` 请求了但没回的列 `missingFields`、逐只请求里撞行数上限的证券 `truncatedSecurities`、EDE 整轴没回 `omittedIndicators` / `omittedSecurities`)/ `4` 数据本身写全了,但 `--output` 那个文件在收尾期间被另一次写向同一路径的导出替换(并发跑同一路径才会出现,给它们各自的 `--output` 即可;与 `3` 同时发生时退出 `3`)/ `1` 硬失败。**拿到 3 就必须告知用户缺了哪段,不能当成功静默继续。** 报错行带 `[trace <id>]`,**报障给 Gangtise 时务必带上**。
300
300
 
301
301
  最高频的几个码(全表、「不报错的坑」、`screener` 缺列判据与困境自救见 `references/errors.md`):
302
302
 
@@ -55,12 +55,11 @@ gangtise ai peer-comparison --security-code <code>
55
55
  gangtise ai stock-summary --security <code> [--security <code2> ...]
56
56
  ```
57
57
 
58
- - 🔴 **单次最多 5000 只,CLI 本地拦截**:更大的批次服务端返回 `{total:0, list:[]}`、HTTP 200、无告警,所以 CLI 超过 5000 只直接报错并提示分批。全市场看点按 ≤ 5000 只/批分次提交
59
-
60
- - `--security`(**必选**,可重复):**只收具体证券代码**,单次最多 5000 个(CLI 本地拦截,超过报错并提示分批)
58
+ - `--security`(**必选**,可重复):**只收具体证券代码**,单次最多 6000 个(接口上限,CLI 同值拦截,超过报错并提示分批)——全 A 股可一次提交完
61
59
  - 🔴 **不支持全市场批量**:`aShares` / `hkStocks` 这类市场关键词不被接受,CLI 会本地报错(服务端对它们返 `120001`,读起来像代码写错,容易误判)。需要覆盖一批标的就先用 `reference securities-search` 或自有清单取到代码,再分批传入
62
60
  - **仅支持 A 股和港股**
63
- - **积分**:`3`/条;个股若无看点总结则不在返回列表中,也不扣分
61
+ - **积分**:`3`/条;个股若无看点总结则不在返回列表中,也不扣分——所以**返回行数少于提交数是正常的**,不是丢了数据
62
+ - 🔴 **整批返 0 行别直接读成「这批都没有看点」**:它与「确实都没有看点」长得一样。挑其中一两只单查确认后再下结论
64
63
  - 返回字段:`securityCode` / `securityName` / `summary`(精炼投研总结)/ `date`(更新日期 `yyyy-MM-dd`)
65
64
 
66
65
  **示例:**
@@ -14,11 +14,11 @@
14
14
  | A股盈利预测 / 一致预期(含预测 EPS) | `fundamental earning-forecast`;EDE 搜到的基本 / 稀释 EPS 是已实现值,不能替代预测 |
15
15
  | A股估值历史分位 | `fundamental valuation-analysis` |
16
16
  | **估值指标的历史序列**(分位 / 回测 / 择时) | 两个接口口径不同、都要交叉核——EDE 按正式财报披露日切换财报口径,`valuation-analysis` 按业绩快报切,见下方专门小节 |
17
- | 开高低收 / 成交量等行情与 K 线 | `quote`;免费且支持多证券批量 |
17
+ | 开高低收 / 成交量等行情与 K 线 | `quote`;免费且支持多证券批量。**换接口不换口径**:`quote` 的 `volume` 与 EDE `qte_vol` 逐位相同(A股 / 港股 / 美股,单位均为股)。EDE 只在 `quote` 不提供的口径上才需要——周 / 月 / 季 / 年 / 区间成交量 |
18
18
  | 单证券资金流向(要占比字段) | `quote fund-flow`;免费,且有 EDE 没有的 `*Ratio` 占比。多证券批量取某一两个档位才用 EDE 的 `flow_*` |
19
19
  | 单证券三大报表全部科目 | 对应 `fundamental` 利润表 / 资产负债表 / 现金流量表命令 |
20
20
 
21
- EDE 不是“搜到就优先”。取数前必须核对:① `indicatorName` + `description` 与目标语义一致;② `scopeList` 覆盖**全部**目标市场和证券类型;③ `parameterList` 的必填参数与枚举可满足。`scopeList` 缺失 / `null` / 空或任一项不符,都视为无法证明覆盖并回退上表的专用接口;专用接口也不支持目标市场时,如实说明当前 CLI 无可用口径,不能用其他语义代替。`valuation-analysis` / `earning-forecast` 仅支持 A 股,港 / 美股的**估值历史分位**与**盈利预测**当前无可用口径。但**估值指标本身别照抄旧结论**:`finc_pe_ttm`(PE TTM) 港股有数;`finc_pb_mrq`(PB MRQ) 的港股覆盖是**部分**的(部分大盘股有数,另一些为 `null`)。⚠️ **本文里所有「仅 A 股」「无数据」这类否定结论都只是某个时点的抽查**——服务端在持续补数据,正面结论过期会给错数,负面结论过期则会让你白白拒掉一个现在能跑的查询(不报错、不告警,只是少调一次)。**一律以当次 `scopeList` + 抽查一行为准。**`search` 免费,EDE 取数按单元格计费;除多证券批量的效率收益外,仍优先免费 / 低价的 `quote` 或 `fundamental`。
21
+ EDE 不是“搜到就优先”。取数前必须核对:① `indicatorName` + `description` 与目标语义一致;② `scopeList` 覆盖**全部**目标市场和证券类型;③ `parameterList` 的必填参数与枚举可满足。`scopeList` 缺失 / `null` / 空或任一项不符,都视为无法证明覆盖并回退上表的专用接口;专用接口也不支持目标市场时,如实说明当前 CLI 无可用口径,不能用其他语义代替。`valuation-analysis` / `earning-forecast` 仅支持 A 股,港 / 美股的**估值历史分位**与**盈利预测**当前无可用口径。但**估值指标本身别照抄旧结论**:`finc_pe_ttm`(PE TTM) 与 `finc_pb_mrq`(PB MRQ) 港股都有数。⚠️ **本文里所有「仅 A 股」「无数据」这类否定结论都只是某个时点的抽查**——服务端在持续补数据,正面结论过期会给错数,负面结论过期则会让你白白拒掉一个现在能跑的查询(不报错、不告警,只是少调一次)。**一律以当次 `scopeList` + 抽查一行为准。**`search` 免费,EDE 取数按单元格计费;除多证券批量的效率收益外,仍优先免费 / 低价的 `quote` 或 `fundamental`。
22
22
 
23
23
  ## 指标搜索 `indicator search`
24
24
 
@@ -30,9 +30,9 @@ gangtise indicator search --keyword <text> [--limit <n>]
30
30
  - `--limit`:返回条数上限,默认 50,最大 100
31
31
  - 默认 `--format table` 只适合浏览名称;正式路由 / 取数前必须加 `--format json`,才能完成语义、`scopeList`、`parameterList` 三项校验
32
32
  - 返回字段:`indicatorCode` / `indicatorName` / `description`(算法与口径)/ `scopeList`(该指标适用的市场 + 证券类型 + `usageRestriction`)/ `parameterList`(可传的 `--indicator-param` 参数名及枚举)/ `score`
33
- - **市场范围按指标判断**:`scopeList` 会返回实际覆盖范围,且指标之间不同;不能笼统写成每个指标都覆盖 A / 港 / 美股。覆盖面在持续扩,别把抽查结论当常量:例如 `qte_mkt_cptl`/`shr_tot` 覆盖 A/港/美股,`finc_pe_ttm` 覆盖 A/港股,`finc_pb_mrq` 覆盖 A 股 + **部分**港股,`is_op_rev_ttm`/`is_shnp_ttm` 覆盖 A/港股。目标列表含任一 scope 外证券时,本批 EDE 校验不通过,应回退专用接口
33
+ - **市场范围按指标判断**:`scopeList` 会返回实际覆盖范围,且指标之间不同;不能笼统写成每个指标都覆盖 A / 港 / 美股。覆盖面在持续扩,别把抽查结论当常量:例如 `qte_mkt_cptl`/`shr_tot` 覆盖 A/港/美股,`finc_pe_ttm` / `finc_pb_mrq` 覆盖 A/港股,`is_op_rev_ttm`/`is_shnp_ttm` 覆盖 A/港股。目标列表含任一 scope 外证券时,本批 EDE 校验不通过,应回退专用接口
34
34
  - **`scopeList[].usageRestriction`**:该指标在具体接口上的限制,`null` = 无限制。⚠️ **它是提示不是硬约束**——标着「不支持指标时间序列接口」的 `qte_vol_intvl` 调时序照样返数据、不会被拦下。当成「口径可能不对、结果别当真」看,别指望它会报错
35
- - ⚠️ **`scopeList` 是声明不是保证**:它可能**超前于数据**(声明覆盖但暂无数据,如 `finc_pb_mrq` 声称覆盖 A股+港股而港股只有部分有数),也可能**滞后于数据**(已有数据尚未声明)。**「某某仍无数据」这类否定结论过期得和正面结论一样快**,照着它走会白白拒掉一个现在能跑的查询,而且不报错。**判断成本很低**:无数据会保留行列、给一个占位单元格,抽查一行就能看出覆盖情况;code 写错则直接报 `100003`,两种情形不会混淆。占位值统一是 `null`(详见下方「缺数据 vs 代码写错」)
35
+ - ⚠️ **`scopeList` 是声明不是保证**:它可能**超前于数据**(声明覆盖但该市场暂无数据),也可能**滞后于数据**(已有数据尚未声明)。**「某某仍无数据」这类否定结论过期得和正面结论一样快**,照着它走会白白拒掉一个现在能跑的查询,而且不报错。**判断成本很低**:无数据会保留行列、给一个占位单元格,抽查一行就能看出覆盖情况;code 写错则直接报 `100003`,两种情形不会混淆。占位值统一是 `null`(详见下方「缺数据 vs 代码写错」)
36
36
  - 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE):用 `AAPL.O`,`AAPL.US` 取不到数据
37
37
 
38
38
  ```bash
@@ -192,21 +192,6 @@ gangtise indicator cross-section --indicator qte_close --security 600519.SH \
192
192
 
193
193
  ## 必填参数与错误码(取数前必读)
194
194
 
195
- ### 取数可靠性:`qte_vol` 等指标在某些日期上会随机返 `null`
196
-
197
- 🔴 **同一条请求多次调用,结果会在「有值」和 `null` 之间跳。** 这**与「是不是最新交易日」无关**——是**特定日期**的数据在部分副本上缺失。受影响的**只是部分标的**(同一批里其他证券稳定);同一条请求内,受影响的证券**全有或全无**(不会一半有一半没有)。
198
-
199
- **做法,按优先级**:
200
-
201
- 1. 🟢 **只要日成交量 / 成交额,别用 EDE,直接用 `quote day-kline`** —— 稳定,`amount` 与分钟线加总精确相等。**不用重试、不用换日期、不丢数据**;它的 `volume` 与 EDE `qte_vol` **逐位相同**(A股 / 港股 / 美股,单位同为股),**没有精度损失**
202
- 2. 确实需要 EDE 才有的口径时(周 / 月 / 季 / 年 / 区间成交量等 `quote` 不提供的),**重试,但不要设固定次数上限** —— 出现过连续多次全 `null`。**若必须设上限,超限时要显式报错退出,绝不能把最后一次的 `null` 当成「无数据」写进结果**
203
-
204
- ⚠️ **不要用「改取前一个交易日」当通用兜底**:它可能在某个个案上碰巧有效,但坏的是哪一天并无规律,而且会丢掉那一整天的数据。
205
-
206
- **单次 `null` 不能读成「该股当日无数据」** —— 它与真无数据无法区分,且退出码 0。判别法:**同一条请求再打几次**,只要出现过非 `null` 就说明数据是有的。
207
-
208
- ---
209
-
210
195
  **缺数据 vs 代码写错**:
211
196
 
212
197
  **缺数据一律保留行列并给一个占位单元格**,连单指标 × 单证券的 1×1 缺口也有行有列、不是空表。判据是**代码认不认识**,跟有没有数据无关:
@@ -232,7 +217,7 @@ gangtise indicator cross-section --indicator qte_close --security 600519.SH \
232
217
 
233
218
  | 查法 | 服务端返回 |
234
219
  | :--- | :--- |
235
- | `--indicator finc_pb_mrq --security 09992.HK`(这一只港股无 PB 数据;同指标下其他港股有数) | 1 行,`finc_pb_mrq: null`,**退出 0** |
220
+ | `--indicator mgn_bal --security 00700.HK`(融资融券余额不覆盖港股) | 1 行,`mgn_bal: null`,**退出 0** |
236
221
  | `--indicator qte_close --date 2027-01-04`(未来日期) | 1 行,`qte_close: null`,**退出 0** |
237
222
  | `--indicator qte_close --indicator not_a_real_code`(有对照物) | `100003 指标 not_a_real_code 不存在` |
238
223
  | `--indicator not_a_real_code`(**无对照物**) | `100003 指标 not_a_real_code 不存在` ← 同样报错 |
@@ -265,7 +250,7 @@ gangtise indicator cross-section --indicator qte_close --security 600519.SH \
265
250
  | `100001` | **缺少必填参数**:如 `universe` 没传 | 补齐 `--indicator`/`--security` |
266
251
  | `100003`@400 | 入参/表达式错误:`time-series` 传了「多指标 × 多证券」、`expression` 引用未声明变量、`indicatorParamList` 的 code 不在 `indicatorCodeList` 里 | 按 msg 改;多 × 多改用 `cross-section`。CLI 已在本地拦截「表达式引用未绑定变量」,不会白发一次请求 |
267
252
  | `140002`@500 | **终态参数错**:指标必填参数缺失、枚举越界(如「参数 adjustType 的值 99 不在有效范围内 [1,2,3,4]」)、表达式语法错误 | **不重试**(CLI 已把 140002 列为终态码)。读 `search --format json` 的 `parameterList` 改参数名/取值 |
268
- | `999999` | 系统故障。「无数据」不用此码,所以它基本只剩真故障。⚠️ 别把它和空表混为一谈:无数据是占位单元格(统一 `null`),空表表示整轴 code 未识别或参数名写错 | CLI 对 indicator 端点**不重试此码**;确认参数无误仍报错就是服务端问题 |
253
+ | `999999` | 系统故障。「无数据」不用此码,所以它基本只剩真故障。⚠️ 别把它和空表混为一谈:无数据是占位单元格(统一 `null`),空表表示整轴 code 未识别或参数名写错 | CLI 对 indicator 端点**不重试此码**;确认参数无误仍报错,稍后再试或联系平台支持 |
269
254
  | `110003` | **超出账号数据权限的时间范围**。窗口按**账号**配、不按接口配——`cross-section` / `time-series` / `screener` 同界,`quote day-kline` 也在同一条界上 | 把日期移进范围;整段区间都早于下界时缩短窗口无用,**换接口绕不过去**,要更长历史联系客户经理开通 |
270
255
  | `130001`(旧 `410004`) | 数据未找到,或**该指标无权限**(内层信封失败会带具体 msg,如"指标无权限";此码被服务端复用) | 检查查询条件与指标权限;换证券/日期仍失败多为无权限,联系管理员开通 |
271
256
 
@@ -360,7 +345,7 @@ gangtise indicator cross-section --indicator scr_indu_citic --indicator scr_indu
360
345
  ## 取数最佳实践
361
346
 
362
347
  - **先 search 做三项校验**:看 `indicatorName` + `description` 确认语义和口径,看 `scopeList` 确认覆盖全部目标市场 / 证券类型(并看 `usageRestriction` 有无接口限制),再看 `parameterList` 补齐必填参数(required)并核对专属参数**名称**与枚举(`adjustType`/`scale`/`currency` 等);任一不符就回退专用接口。**参数名一律以 search 返回的 `parameterList` 为准,不要照抄任何示例里的参数名**——参数名会随版本调整,写错会报 `100003` 并指出是哪个指标的哪个参数,按报错改即可。
363
- - **`scopeList` 声称覆盖 ≠ 每只都有数**:`finc_pb_mrq` 的 `scopeList` 写着 A股+港股,港股只有部分有数——它是**部分覆盖**,既不能当「全覆盖」也不能当「无覆盖」。**判断成本很低**:无覆盖会留下占位单元格(行列都在,跟同批查了什么无关),抽查一行即可判定,不用再靠对照组反推。跨市场批量取数后仍建议**逐市场抽查一行**,别默认 scopeList 就是事实——反过来也一样,**否定结论同样会过期**。
348
+ - **`scopeList` 声称覆盖 ≠ 每只都有数**:声明可能超前于数据,同一指标下也可能只有部分证券有数,既不能当「全覆盖」也不能当「无覆盖」。**判断成本很低**:无覆盖会留下占位单元格(行列都在,跟同批查了什么无关),抽查一行即可判定,不用再靠对照组反推。跨市场批量取数后仍建议**逐市场抽查一行**,别默认 scopeList 就是事实——反过来也一样,**否定结论同样会过期**。
364
349
  - **公司类型决定有没有这个科目**:财务科目分公司类型——银行有「存放同业」、券商有「客户资金存款」、保险有「预收保费」,一般企业没有。某指标对茅台返回 `null`(无此科目),换到对应类型证券(招行/中信/平安)就有数。
365
350
  - **日期路由**:
366
351
  - 财务报表类(`bs_`/`is_`/`cf_`/`div_`/`shr_`,以及 description 明确按报告期统计的 `finc_`)→ 多数用**报告期末**(Q1 `2026-03-31`、年报 `2025-12-31`,无需是交易日)。🔴 **但 `_ttm` 后缀是整族例外,必填 `tradeDate`**——`is_*_ttm` / `cf_*_ttm` / `div_cash_ttm` / `finc_*_ttm` 一律走交易日。**一眼验证**:在一个**非报告期末的普通交易日**直接 `--date` 取 `is_op_rev_ttm`,照样返真值;反过来给它传 `reportDate` 会报 `100003 不支持参数 reportDate; 缺少必填参数 tradeDate`。同族对照:普通 `is_op_rev` 只传 `--date` 则报「不支持参数 tradeDate; 缺少必填参数 reportDate」——**两者恰好相反**。**所以前缀只是提示,判据仍是上面 `--date` 那条:读 `parameterList` 里必填的是哪个,别按 code 前缀推断**
@@ -71,9 +71,15 @@
71
71
 
72
72
  `partial: true`,保留已取到的部分;stderr 有 warning,`--format json` 才看得见标记,table/csv/jsonl 只有数据行、看不出问题。**拿到 3 就必须告知用户缺了哪段,不能当成功静默继续。**
73
73
 
74
- - 触发场景:翻页 / K 线分片有页失败、服务端返回行数与 `total` 矛盾(提前短页)、`total` 撞服务端上限(`totalCapped`,见 opinion 家族)、`quote` 系带 `--field` 时请求了但服务端没回的列(`missingFields`)。附带定位字段:页失败 `failedPages`;分片失败 `failedShards`、分片撞行数上限 `truncatedShards`(均带日期区间可缩窗补拉);缺列 `missingFields`(字段名写错或已下线,按 `references/fields.md` 核对);多证券逐只请求里撞行数上限的证券 `truncatedSecurities`。EDE 的 `omittedIndicators` / `omittedSecurities` 仍在,但写错的 code 在服务端就被 `100003` 拒了(退出 1),这条路基本收不到样本
74
+ - 触发场景:翻页 / K 线分片有页失败、服务端返回行数与 `total` 矛盾(提前短页)、`total` 撞服务端上限(`totalCapped`,见 opinion 家族)、`quote` 系带 `--field` 时请求了但服务端没回的列(`missingFields`)。附带定位字段:页失败 `failedPages`;分片失败 `failedShards`、分片撞行数上限 `truncatedShards`(均带日期区间可缩窗补拉)、只有部分分片返回的列 `droppedColumns`(合并结果的列集取自第一个有数据的分片,多出来的列放不下,需要就缩小日期区间单独拉);缺列 `missingFields`(字段名写错或已下线,按 `references/fields.md` 核对);多证券逐只请求里撞行数上限的证券 `truncatedSecurities`。EDE 的 `omittedIndicators` / `omittedSecurities` 仍在,但写错的 code 在服务端就被 `100003` 拒了(退出 1),这条路基本收不到样本
75
75
  - **EDE 代码写错是退出码 1 + `100003`,msg 里直接点名是哪个 code**(美股后缀用 `.O`/`.N`,不是 `.US`),不用从退出码 3 反推。真的没覆盖是占位单元格(统一 `null`)+ 退出码 0
76
- - **`screener` 的缺列按表达式的布尔结构判**:把缺列的变量当作「无法求值」,看整个表达式**是否还有一条能成立的分支**——`A && B` 要两边都可求值,`A || B` 只要一边。**一条分支都不剩 → 退出码 1、不输出任何结果**(返回的行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过);**还有分支可求值,或缺的只是未参与表达式的辅助变量 → `partial` + 退出码 3**。所以 `F1 || F2` 缺 F1 是降级,缺两个则是致命;`F1 && (F2 || F3)` 缺 F1 仍是致命。⚠️ **前提是服务端返回了命中行**——零命中时走「nothing matched」+ **退出码 0**。**空集别急着当成「条件成立但无标的符合」**:指标码写错会直接报 `100003`(退出 1),剩下**三种成因产生逐字相同的输出**——① 真的没有标的满足条件;② **日期没落在报告期末**(报告期类指标此时整批 `null`);③ **该指标不覆盖这批证券**(拿 A 股专属指标查港美股,如 `finc_pb_mrq` / `mgn_*`)。②③ 都要靠单查一行确认,不能只改日期
76
+ - **`screener` 的缺列按表达式的布尔结构判**:把缺列的变量当作「无法求值」,看整个表达式**是否还有一条能成立的分支**——`A && B` 要两边都可求值,`A || B` 只要一边。**一条分支都不剩 → 退出码 1、不输出任何结果**(返回的行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过);**还有分支可求值,或缺的只是未参与表达式的辅助变量 → `partial` + 退出码 3**。所以 `F1 || F2` 缺 F1 是降级,缺两个则是致命;`F1 && (F2 || F3)` 缺 F1 仍是致命。⚠️ **前提是服务端返回了命中行**——零命中时走「nothing matched」+ **退出码 0**。**空集别急着当成「条件成立但无标的符合」**:指标码写错会直接报 `100003`(退出 1),剩下**三种成因产生逐字相同的输出**——① 真的没有标的满足条件;② **日期没落在报告期末**(报告期类指标此时整批 `null`);③ **该指标不覆盖这批证券**(拿 A 股专属指标查港美股,如 `mgn_*` 融资融券系列)。②③ 都要靠单查一行确认,不能只改日期
77
+
78
+ ## 退出码 4:数据写全了,但文件被另一次导出替换
79
+
80
+ 本次导出的数据完整写出了,`--output` 指向的文件却在收尾期间被**另一次写向同一路径的导出**替换掉——文件在,只是不是这条命令的产物。stderr 会给出两边的 `sha256` 前缀。给并发的导出各自的 `--output` 即可。
81
+
82
+ 与 `3` 分开是因为行没有缺、缺的是目的地。**两者同时发生时退出码是 `3`**——少了行是更重的那一个,替换另在 stderr 说明。
77
83
 
78
84
  ## 其他场景
79
85
 
@@ -12,17 +12,15 @@
12
12
  >
13
13
  > **一眼自检(仅适用于个股)**:`amount ÷ volume` 得到的均价应落在当日 `[low, high]` 区间内。
14
14
  >
15
- > ⚠️ **这个自检有两类必然失败,不要据此判定数据有问题**:
15
+ > ⚠️ **这个自检有三类必然失败,不要据此判定数据有问题**:
16
16
  >
17
17
  > 1. **指数不适用**。指数的 OHLC 是**点位**,而 `amount` / `volume` 是成分股合计,两者量纲不同——比值算出来是成分股均价(上证 / 深成 ≈ 20 元),永远落不进四千点的区间,**数据本身是对的**。
18
18
  > 2. 🔴 **美股的 `quote realtime`**。它的 `amount` 是 **`null`**(接口不提供该字段,见下方 realtime 字段表),自检算不出来。**要美股的实时成交额,只能用别的口径**(收盘后用 `day-kline`,或 EDE `qte_amt`)。
19
19
  > 3. **全球指数**(`SPX.SPI` 等 20 个):realtime 与分钟 K 的 `volume` / `amount` 都是 `null`,日 K 只有 `amount` 是 `null`。
20
20
  >
21
- > 🔴 **分钟线加总对日线时,先看收盘那根**:正常情况下 **Σ分钟 `volume` = 日线 `volume`、Σ分钟 `amount` = 日线 `amount`**,两者都**精确相等**(正常交易日 241 根,逐根加总与日线一致)。
21
+ > **分钟线加总对日线**:Σ分钟 `volume` 与日线 `volume` **精确相等**(正常交易日 241 根);Σ分钟 `amount` 与日线 `amount` 只差**浮点尾差**(逐根相加的累积误差,相对量级 1e-8 以内),别按「逐位相同」判等。
22
22
  >
23
- > ⚠️ **但 `15:00` 那根(收盘集合竞价)会随机返 `volume=0, amount=0`**,此时 Σ分钟比日线少约 1.2%。**这是逐只、逐日、且同一只票重复取也未必一致的**——同一个交易日里,有的标的每次都对得上(Σ分钟 `amount` 与日线只差浮点级),有的则时有时无。**所以不能拿一只票的一次结果推断当天整批数据**,也与「是不是最新交易日」无关。
24
- >
25
- > **最省事的对账法:直接拿 `quote day-kline` 的 `amount` 比**——差额若恰好等于某一根,就是那一根在翻。(`14:58` / `14:59` 恒为 0 是**正常的**:14:57 后进入收盘集合竞价,只申报不撮合,成交在 15:00 一次性发生。)
23
+ > (`14:58` / `14:59` 恒为 0 是**正常的**:14:57 后进入收盘集合竞价,只申报不撮合,成交在 `15:00` 那一根一次性发生——**收盘那根在序列里**,241 根的末根就是它。)
26
24
 
27
25
  ### 日K线 `quote day-kline`(A股 / 港股 / 美股 / ETF / 各类指数含全球指数;旧的 `day-kline-hk` / `day-kline-us` 已下线,字段相同)
28
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {