gangtise-openapi-cli 0.37.1 → 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.37.1 — 2026-08-31**:**文档修正,无代码变更**。① `indicator screener` 用静态属性选股的示例补进 README——`pty_*`(经营范围·注册地…)/ `scr_*`(上市板块·ISIN…)两族用 `--indicator-param "F1:"`(冒号后留空)声明该指标不吃查询日期后,可直接用于条件选股(v0.36.0 起支持),此前 README 示例仍写着要先用 `cross-section` 取回再本地筛。② **数据权限时间范围的说明按实测更正**:这个窗口按**账号**配、不按接口配——`indicator` 的 `cross-section` / `time-series` / `screener` 与 `quote day-kline` 在同一条边界上,**撞到 `110003` 换接口绕不过去**,正确做法是把日期移进权限范围,或联系客户经理开通更长历史;文档此前建议的「`screener` 撞界改用 `cross-section` 拉数再本地筛」已不适用。③ EDE 文本筛选示例换成有区分度的条件,避免示例条件把整个板块原样返回、看不出筛选是否生效。
10
- - **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` 一直是这个行为);③ 下载请求的超时改为与其他请求同一套解析逻辑,行为对齐。
11
- - **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` 现在可以直接用于条件选股(此前只能在 `cross-section` 取回后本地筛),写法与截面一致、可与真实参数共存。**全量拉取的 `total` 封顶探测恢复覆盖 `ai hot-topic`**(v0.35.0 曾跳过)。另:EDE 报错提示同步更新;补充计费说明(`ai hot-topic` 50/篇 的「篇」= 一整份报告;按篇/按条计费的接口查不到内容不扣分)。
12
- - **v0.35.0 — 2026-08-16**:新增 `--indicator-param "<code>:"`(冒号后留空)声明「这个指标不要查询日期」,用于 `parameterList` 里没有日期参数的指标——`pty_*`(经营范围/注册地/法定代表人…)、`scr_*`(上市市场/上市板块/ISIN…)两族,以及 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg`。这类指标此前在 `indicator cross-section` 上取不到数(`--date` 必填且会注入 `tradeDate`,而它们不收,整条请求被拒);该写法可与真实参数共存(`"code:" + "code:fiscalYear=2025"`)。**EDE 日期参数报错提示重写**:拆成五种报文形态分别给建议,服务端同时点名「不该有的键」和「缺的键」时直说换哪个,只说了一半时不再瞎猜,多指标批量报错时不再用单数口吻指向其中一个指标。另:`--rank-type` 的说明按实测更正(差别大小取决于关键词,`--search-type` 不影响 `--rank-type 1` 取回哪些条目);`ai hot-topic` 全量拉取跳过 `total` 封顶探测(**已于 v0.36.0 撤回**——该探测不额外计费)。
13
- - **v0.34.1 — 2026-08-15**:EDE 报告期类指标传错日期参数时,报错里直接给出该改的 CLI 写法(此前只有服务端那句「缺少必填参数 reportDate」,要自己推断该用 `--indicator-param`);该提示对 `100001` / `100003` 两个错误码都生效,并注明少数指标两个日期都要、另有指标要 `fiscalYear`,一律以 `indicator search` 的 `parameterList` 为准。另修复下载文件名缓存在并发写入下可能丢条目的问题(单进程使用不受影响)。
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` 与日线只差浮点尾差。
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` 不需要环境里先有凭证。
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 文本筛选示例换成有区分度的条件,避免示例条件把整个板块原样返回、看不出筛选是否生效。
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` 一致);③ 下载请求的超时改为与其他请求同一套解析逻辑,行为对齐。
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/篇 的「篇」= 一整份报告;按篇/按条计费的接口查不到内容不扣分)。
14
14
 
15
15
  ### 历史里程碑
16
16
 
@@ -79,7 +79,7 @@ export GANGTISE_TOKEN_CACHE_PATH=... # 覆盖 token 缓存路径(默认 ~/.c
79
79
 
80
80
  - [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
81
81
  - [Codex](https://github.com/openai/codex) — `~/.codex/skills/`
82
- - [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
82
+ - [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/workspace/skills/`
83
83
  - [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
84
84
 
85
85
  Skill 目录结构:
@@ -96,7 +96,9 @@ gangtise-openapi/
96
96
  │ ├── insight.md # 投研内容命令(研报 / 观点 / 纪要 / 公告 / 外资)
97
97
  │ ├── quote.md # 行情命令(A股/港股/指数 K 线)
98
98
  │ ├── reference-and-lookup.md # GTS Code 搜索与枚举速查
99
+ │ ├── tool.md # PDF 解析(file-parse)
99
100
  │ └── vault.md # 云盘/录音/会议/群消息/股票池
101
+ ├── errors.md # 错误码全表、不报错的坑、退出码 3 的判读、Troubleshooting
100
102
  ├── examples.md # 典型场景的端到端示例
101
103
  ├── fields.md # K线/财务字段中英文对照速查表
102
104
  ├── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
@@ -115,7 +117,7 @@ cp -r "$SKILL_SRC" ~/.claude/skills/gangtise-openapi
115
117
  cp -r "$SKILL_SRC" ~/.codex/skills/gangtise-openapi
116
118
 
117
119
  # OpenClaw
118
- cp -r "$SKILL_SRC" ~/.openclaw/skills/gangtise-openapi
120
+ cp -r "$SKILL_SRC" ~/.openclaw/workspace/skills/gangtise-openapi
119
121
 
120
122
  # Hermes
121
123
  cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
@@ -170,11 +172,11 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
170
172
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
171
173
  | | `sector-search` | 板块 ID 搜索(返回层级路径) |
172
174
  | | `sector-constituents` | 板块成分股查询 |
173
- | **Quote** | `day-kline` | 历史日K线——A股/港股/美股个股 + 交易所/概念/行业指数,可混查 |
175
+ | **Quote** | `day-kline` | 历史日K线——A股/港股/美股个股 + 沪深 ETF + 交易所/概念/行业指数 + 20 个全球指数,可混查 |
174
176
  | | `day-kline-hk` / `day-kline-us` | ⚠️ 已下线,能力并入 `day-kline`(接口仍可调,但不校验证券代码) |
175
177
  | | `index-day-kline` | ⚠️ 已下线,能力并入 `day-kline`;但仍是取「全部沪深京指数」(`--security all`)和拿指数名称(`securityName`)的唯一方式 |
176
- | | `minute-kline` | 分钟K线——沪深A股 + 各类指数(一次一只) |
177
- | | `realtime` | 个股实时行情快照(A股/港股/美股) |
178
+ | | `minute-kline` | 分钟K线——沪深A股 / ETF + 各类指数含全球指数(`--security` 可重复,逐只并发合并) |
179
+ | | `realtime` | 实时行情快照——A股/港股/美股个股 + 沪深 ETF + 各类指数含全球指数 |
178
180
  | | `fund-flow` | A股个股日资金流向(沪深京;小/中/大/特大单 + 主力净流入) |
179
181
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
180
182
  | | `income-statement-quarterly` / `cash-flow-quarterly` | A股利润表/现金流量表(单季度) |
@@ -259,8 +261,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
259
261
  - **并发翻页**:自动翻页接口的首页拿到 `total` 后,剩余页用 `Promise.all` 并发拉取(默认并发数 5,可通过 `GANGTISE_PAGE_CONCURRENCY` 调整)。20 页查询从串行 ~10s 降到 ~2s。
260
262
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
261
263
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
262
- - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
263
- - **自动重试**: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` 不重试**——重放一次已计费的查询没有意义(该码 2026-08-01 前还兼表「查询无数据」,现在无数据是保留行列的占位单元格(统一 `null`),空表另表示整轴 code 未识别)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
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` / `droppedColumns` / `missingFields` 等全部完整性标记、抓取时间与时区、CLI 版本,以及数据文件的字节数 `bytes` 与内容哈希 `sha256`。命令行与结果里的 key / secret / token 类字段写成 `[redacted]`。元信息在数据文件发布之后才就位,导出失败不会留下新数据配旧元信息。**转交或归档前建议核一次**:`shasum -a 256 <文件>` 与元信息里的 `sha256` 相等,才说明这份元信息描述的就是旁边这份数据——两个进程同时导出到**同一个 `--output`** 时,最终文件一定是其中某一次的完整产物,但旁边的元信息有可能来自另一次,核哈希就能发现。**输的那一次自己也会报**:收尾时回读一次 `--output`,与本次写出的哈希不符就在 stderr 说明「这个文件不是本次命令的产物」并**退出码 4**。这两种格式的文件本身只有数据行,转交之后靠它核验是否完整;`json` 自带标记,不生成。
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 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
264
267
 
265
268
  <!-- no-replay-endpoints
266
269
  上面那句点名的「不重放」端点,完整清单如下(endpoint key,与 `gangtise raw list` 一致):
@@ -284,7 +287,7 @@ tool.file-parse.submit
284
287
  vault.my-conference.download
285
288
  -->
286
289
  - **Token 自愈**:调用返回 `0000001008` / `999002` 时自动强制刷新 Token 并重试一次。
287
- - **K线/资金流向自动分片**:`quote day-kline --security aShares|hkStocks|usStocks`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、港股 2 天/片;已下线的 `day-kline-hk`/`day-kline-us`/`index-day-kline` 用 `all`,分别 2/1/15 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
290
+ - **K线/资金流向自动分片**:`quote day-kline --security aShares|hkStocks|usStocks`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、港股 2 天/片;已下线的 `day-kline-hk`/`day-kline-us`/`index-day-kline` 用 `all`,分别 2/1/15 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。**显式多证券**的日 K 在「证券数 × 交易日数」超过 `--limit` 时自动逐只请求并按传入顺序合并(撞上限的证券标 `partial` + `truncatedSecurities`);`minute-kline` 的 `--security` 可重复,逐只并发请求后合并。
288
291
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
289
292
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
290
293
 
@@ -322,10 +325,10 @@ vault.my-conference.download
322
325
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
323
326
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
324
327
  - 安全上限:自动翻页最多 1000 页,防止异常循环
325
- - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
326
- - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。**2026-08-15 起这个分支基本收不到样本**——服务端现在对解析不了的代码直接报 `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` 硬失败
327
- - **分页端点返回 `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 字节——**按文件大小判空的脚本要留意 csv 的这 3 个字节**。
328
- - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:分页端点的 `total` 若被服务端封顶(返回一个固定上限而非真实条数),省略 `--size` 的全量拉取会**正好取满那个上限就停、且不报任何异常**——导出的文件是截断的却看不出来。现在全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
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
+ - **分页端点返回 `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
+ - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:分页端点的 `total` 若被服务端封顶(返回一个固定上限而非真实条数),省略 `--size` 的全量拉取会**正好取满那个上限就停、且不报任何异常**——导出的文件是截断的却看不出来。全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
329
332
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
330
333
 
331
334
  ## 智能文件命名
@@ -334,10 +337,11 @@ vault.my-conference.download
334
337
 
335
338
  1. **缓存优先** — 如果之前执行过对应的 `list` 命令,标题已缓存在 `~/.config/gangtise/title-cache.json`,直接使用,**无额外 API 调用、无额外积分**
336
339
  2. **兜底** — 缓存未命中时使用服务器返回的原始文件名,无则 `{type}-{id}.{ext}`
340
+ 3. **同名不覆盖** — 目标文件已存在时自动加 `-1`、`-2` 后缀;多个下载并发写同一名字时各得其名
337
341
 
338
342
  推荐工作流:先 `list` 再 `download`,文件名自动正确且零额外成本。
339
343
 
340
- 🔴 **缓存未命中时不会自动回查 list 接口**(v0.37.0 起)。回查要拉最近 200 条记录(4 次请求),而上面 12 个命令里有 9 个的 list 按 **0.1 积分/条**计费,约 20 积分——这笔开销只用于取一个更易读的文件名(下载本身 10–50 积分),所以改成按需开启。需要时加 `--resolve-title`:
344
+ 🔴 **缓存未命中时不会自动回查 list 接口**。回查要拉最近 200 条记录(4 次请求),而上面 12 个命令里有 9 个的 list 按 **0.1 积分/条**计费,约 20 积分——这笔开销只用于取一个更易读的文件名(下载本身 10–50 积分),所以改成按需开启。需要时加 `--resolve-title`:
341
345
 
342
346
  ```bash
343
347
  gangtise insight research download --report-id 432092410345574400 --resolve-title
@@ -468,13 +472,17 @@ gangtise reference sector-constituents --sector-id 1000001005 --format json
468
472
 
469
473
  ```bash
470
474
  gangtise quote day-kline --security 600519.SH --start-date 2026-03-01 --end-date 2026-03-31
471
- # 查最近/最新 K 线建议显式传 --start-date/--end-date;只传 --limit 会截取查询窗口开头,不等于最近N条
472
- gangtise quote day-kline --format json
473
- # 全市场查询:关键字是 aShares / hkStocks / usStocks,必须单独传(旧的 --security all 已不再支持)
475
+ # --field 只回点名的列、不自动附带身份列(fund-flow 除外):日 K 加 securityCode / tradeDate,分钟 K 加 securityCode / tradeTime,realtime 加 securityCode
476
+ gangtise quote day-kline --security 600519.SH --security 000858.SZ --start-date 2026-03-01 --end-date 2026-03-31 --field securityCode --field tradeDate --field close
477
+ # 查最近/最新 K 线要显式传 --start-date/--end-date;只传 --limit 会截取查询窗口开头,不等于最近 N 条
478
+ gangtise quote day-kline --security 600519.SH --start-date 2026-08-01 --end-date 2026-08-31 --format json
479
+ # 全市场查询:关键字是 aShares / hkStocks / usStocks,必须单独传(不认 --security all)
474
480
  gangtise quote day-kline --security aShares --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
475
481
  # 港股 / 美股 / 指数都走同一个 day-kline,可混着传
476
482
  gangtise quote day-kline --security 00700.HK --security AAPL.O --start-date 2026-03-01 --end-date 2026-03-31
477
483
  gangtise quote day-kline --security 000001.SH --security 880134.GT --security 821031.SWI --start-date 2026-03-01 --end-date 2026-03-31
484
+ # 沪深 ETF 与 20 个全球指数也走 day-kline(全球指数 amount 为 null;ETF 有 adjustFactor;aShares 关键字不含 ETF,要逐个传)
485
+ gangtise quote day-kline --security 512800.SH --security SPX.SPI --security N225.NKI --start-date 2026-08-01 --end-date 2026-08-31
478
486
  # 港股全市场(自动按 2 天/片分片)
479
487
  gangtise quote day-kline --security hkStocks --start-date 2026-04-01 --end-date 2026-04-10 --format json
480
488
  # 美股全市场(自动按 1 天/片分片)
@@ -485,6 +493,8 @@ gangtise quote index-day-kline --security 000001.SH --security 399001.SZ --start
485
493
  gangtise quote minute-kline --security 600519.SH --start-time "2026-04-15 09:30:00" --end-time "2026-04-15 15:00:00" --field open --field close --field volume
486
494
  # 实时行情:三大市场混合查询
487
495
  gangtise quote realtime --security 600519.SH --security 00700.HK --security AAPL.O --field securityCode --field tradeTime --field latestPrice --field pctChange --field volume --format json
496
+ # 实时行情:ETF 与全球指数(全球指数 volume/amount/amplitude 为 null,tradeTime 是交易所当地时间;美股 amount 为 null)
497
+ gangtise quote realtime --security 512800.SH --security SPX.SPI --security HSI.HI --field securityCode --field tradeTime --field latestPrice --field pctChange --format json
488
498
  # 实时行情:全市场批量(建议配合 --field 精简字段)
489
499
  gangtise quote realtime --security aShares --field securityCode --field latestPrice --field pctChange --field volume --format json
490
500
  # A股个股日资金流向(沪深京;--security aShares 全市场;--limit 上限 10000,超限缩短日期区间分批)
@@ -511,8 +521,8 @@ gangtise fundamental main-business --security-code 600519.SH --breakdown region
511
521
  # 多报告期:--period 可传多个值
512
522
  gangtise fundamental main-business --security-code 600519.SH --breakdown product --period annual --period interim
513
523
  gangtise fundamental valuation-analysis --security-code 600519.SH --indicator peTtm
514
- # 盈利预测(一致预期)
515
- gangtise fundamental earning-forecast --security-code 600519.SH --consensus netIncome --consensus eps --consensus pe
524
+ # 盈利预测(一致预期);roe 单位是百分比(35.6 = 35.6%)
525
+ gangtise fundamental earning-forecast --security-code 600519.SH --consensus netIncome --consensus eps --consensus pe --consensus roe
516
526
  # 利润表(单季度)
517
527
  gangtise fundamental income-statement-quarterly --security-code 600519.SH --fiscal-year 2025 --period q2 --field netProfit
518
528
  # 现金流量表(单季度)
@@ -544,7 +554,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
544
554
  gangtise ai knowledge-batch --query 新能源汽车 --resource-type 10 --resource-type 11 --top 10
545
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
546
556
  gangtise ai one-pager --security-code 600519.SH
547
- # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 6000 个;不支持全市场关键字
557
+ # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 6000 个(更大的批次拆开分次调);不支持全市场关键字
548
558
  gangtise ai stock-summary --security 600519.SH --security 00700.HK --format json
549
559
  gangtise ai investment-logic --security-code 600519.SH
550
560
  gangtise ai peer-comparison --security-code 600519.SH
@@ -617,7 +627,7 @@ gangtise indicator cross-section \
617
627
  --indicator qte_close --indicator qte_vol --indicator qte_mkt_cptl \
618
628
  --security 600519.SH --security 09992.HK \
619
629
  --date 2026-07-31 --format table
620
- # 输出列:security / name / <各指标名>…(v0.30.0 起无 date 列——日期挂在每个指标的参数上)
630
+ # 输出列:security / name / <各指标名>…(无 date 列——日期挂在每个指标的参数上)
621
631
 
622
632
  # 时间序列:多指标 × 单证券 或 单指标 × 多证券(不能多 × 多,否则报 100003)
623
633
  gangtise indicator time-series --indicator qte_close \
@@ -717,7 +727,7 @@ gangtise raw list --format json # key / method / path / description
717
727
  gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
718
728
  ```
719
729
 
720
- 说明:对已标记为自动翻页的 endpoint,`raw call` 也会复用同一套 client 翻页逻辑;这里的 `size` 仍表示最终希望返回的记录数。
730
+ 说明:对已标记为自动翻页的 endpoint,`raw call` 也会复用同一套 client 翻页逻辑,这里的 `size` 仍表示最终希望返回的记录数;`--format jsonl` / `csv` 加 `--output` 时同样逐批写盘并生成 `.meta.json`。`raw call auth.login` 的凭证放在 `--body` 里,不需要环境里先有 AK/SK 或 token。
721
731
 
722
732
  ## 输出格式
723
733
 
@@ -729,7 +739,7 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
729
739
  - `csv`
730
740
  - `markdown`
731
741
 
732
- 所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。
742
+ 所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。`csv` / `jsonl` 落盘时会在旁边写 `<path>.meta.json`(命令、行数、列、完整性标记、抓取时间),`complete: false` 即退出码 3 那次导出,缺了什么看 `result` 里的标记。
733
743
 
734
744
  ## 参数校验
735
745
 
@@ -739,7 +749,7 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
739
749
  - `--size` / `--limit` / `--top`:正整数
740
750
  - `--file-type` / `--resource-type` 以及数值型列表参数:有限数字
741
751
  - 所有 date 参数(`--start-date`/`--end-date`/`--date`/`--report-date`,含 Quote/Fundamental/AI/Alternative/Indicator):`YYYY-MM-DD`、`YYYY/MM/DD` 或 `YYYYMMDD`,统一归一成 `YYYY-MM-DD` 发出(年在后等歧义写法在发请求前拒绝,见下节)
742
- - 所有 `--start-time` / `--end-time`(Insight/Vault/AI 透传、`quote minute-kline`,以及 A 股公告 / `knowledge-batch` 两个转换端点):上述三种日期写法 + 可选的 `[ HH:mm[:ss]]`(秒可省、空格或 `T` 分隔),或 10/13 位 Unix 时间戳(同样归一日期部分、拒绝年在后写法)
752
+ - 所有 `--start-time` / `--end-time`(Insight/Vault/AI 透传、`quote minute-kline`,以及 A 股公告 / `knowledge-batch` 两个转换端点):上述三种日期写法 + 可选的 `[ HH:mm[:ss]]`(秒可省、空格或 `T` 分隔),或 10/13 位 Unix 时间戳(同样归一日期部分、拒绝年在后写法)。两个转换端点把日期与时刻按**北京时间**换算成毫秒,与运行机器的时区无关
743
753
 
744
754
  校验失败会输出 `ValidationError: Invalid ...` 并以非 0 状态退出。
745
755
 
@@ -772,7 +782,7 @@ datetime 参数(`--start-time` / `--end-time`)同理,只归一日期部分
772
782
  |-----------|------|
773
783
  | `ValidationError` | 本地参数校验失败,检查 `--size` / `--limit` / `--from` / `--file-type` 等数值参数 |
774
784
  | `API error (HTTP 4xx/5xx)` | HTTP 层失败;CLI 会把 4xx/5xx 响应视为错误,即使响应体不是标准 `{code,msg,data}` 信封 |
775
- | `999011` | 开发账号凭证无效(AK/SK 不匹配)——**取代旧 `8000014`/`8000015`**,不再区分是 AK 错还是 SK 错 |
785
+ | `999011` | 开发账号凭证无效(AK/SK 不匹配;旧码 `8000014`/`8000015`,不区分是 AK 错还是 SK 错) |
776
786
  | `999002` / `0000001008` | Token 无效或已过期(有 AK/SK 时 CLI 自动重登重试一次) |
777
787
  | `999001` / `0000001007` | 请求未携带 token |
778
788
  | `999003` | 未开通接口权限(定制接口需联系客户经理) |
@@ -781,23 +791,23 @@ datetime 参数(`--start-time` / `--end-time`)同理,只归一日期部分
781
791
  | `999010` | 接口地址不存在(`raw call` 的 key 可能已下线,用 `raw list` 核对) |
782
792
  | `999012` / `999013` / `999014` | 账号禁用 / 已过期 / 租户失效 |
783
793
  | `999016` | 调用方 IP 不在允许范围 |
784
- | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点的「无数据」已不再用此码——有效 code 无数据返回占位单元格(统一 `null`),此码基本只剩真故障) |
794
+ | `999999` | Gangtise 系统错误,请稍后重试(EDE 无数据不用此码:有效 code 无数据返回占位单元格 `null`,此码基本只剩真故障) |
785
795
  | `140002` | 终态失败:AI 异步生成失败,或 `indicator` 的参数/表达式错误(枚举越界、语法错)——改参数重提,不重试 |
786
796
  | `100003` | 参数值非法——**最宽的兜底码**;msg 通常已指明字段(如「limit 最小为 1,最大为 10000」),先读 msg |
787
797
  | `100001` | 缺必填参数(msg 带字段名,如「缺少必填参数: reportId」) |
788
- | `100006` | 查询/下载数量超限——**取代旧 `430007`** |
798
+ | `100006` | 查询/下载数量超限(旧码 `430007`) |
789
799
  | `110001` / `110002` | 日期格式错误 / 日期区间非法(起晚于止) |
790
800
  | `120001` | 证券代码无效(用 `reference securities-search` 确认代码与后缀) |
791
- | `130001` | 数据未找到或无指标权限——**取代旧 `410004`** |
792
- | `130002` | 资源不存在——**下载类的兜底码**,`--report-id` 不存在 / 非数字 / `--file-type` 非法都归这里(取代旧 `430004`) |
793
- | `410110` / `410111` | 异步任务生成中(继续轮询)/ 生成失败(终态)——异步端点当前返回的是这两个码;新码为 `140001`/`140002`,CLI 两代都认 |
801
+ | `130001` | 数据未找到或无指标权限(旧码 `410004`) |
802
+ | `130002` | 资源不存在——**下载类的兜底码**,`--report-id` 不存在 / 非数字 / `--file-type` 非法都归这里(旧码 `430004`) |
803
+ | `410110` / `410111` | 异步任务生成中(继续轮询)/ 生成失败(终态)——异步端点返回的是这两个码;新码为 `140001`/`140002`,CLI 两代都认 |
794
804
  | `240001` | 财报期未披露或超出查询期(`earnings-review` 提交阶段即报,不扣积分) |
795
- | `250001` | 不支持该数据源(`knowledge-resource-download` 需正确的 `resourceType + sourceId` 组合)——**取代旧 `433007`** |
805
+ | `250001` | 不支持该数据源(`knowledge-resource-download` 需正确的 `resourceType + sourceId` 组合;旧码 `433007`) |
796
806
  | `900002` | 请求方法不正确(服务端 msg 为「请求类型有误」,HTTP 405) |
797
807
 
798
- > **关于这次错误码重排**:服务端 2026-07-17 重排了 41 个公开码(三层:`999xxx` 服务统一层 / `1xxxxx` 业务通用层 / `2xxxxx` 接口专有层),信封新增 `errorType` 和 `traceId`。2026-07-20 逐码实测发现**迁移是按「错误处理层」而非按业务模块进行的**:同一个接口内,参数校验层与路由层已发新码,方法路由层、token 过滤器、以及异步生成状态仍发旧码。新码信封 `code` 是 JSON 数字且带 `errorType`,旧码是字符串且没有——但这判断的是单条错误路径,不是整个接口;CLI 对两代都能识别。报错行会带 `[trace <id>]`,**报障时请带上它**。
808
+ > **错误码分两代并存**:服务端错误码分三层(`999xxx` 服务统一层 / `1xxxxx` 业务通用层 / `2xxxxx` 接口专有层),信封带 `errorType` 和 `traceId`。按「错误处理层」而不是按业务模块划分:参数校验层与路由层发新码(信封 `code` 是 JSON 数字且带 `errorType`),方法路由层、token 过滤器、以及异步生成状态仍发旧码(字符串、无 `errorType`)——这判断的是单条错误路径,不是整个接口;CLI 对两代都能识别。报错行会带 `[trace <id>]`,**报障时请带上它**。
799
809
  >
800
- > 其余码(`999003`–`999006`、`999012`–`999016`、`100002`、`210001`、`220001`、`230001`、`240002`、`240003`)在实测中未触发到,多被上面的兜底码接管,CLI 仍内置了对应提示。⚠️ 两个需要留意的行为:**枚举值拼错和分页越界在部分端点上会报 `100005`/`100006`、在另一些端点上被静默忽略**(后者按未传该筛选条件处理,结果看着正常但范围不对——v0.32.0 起 CLI 对 `--search-type`/`--rank-type`/`--file-type` 等已知枚举本地拦截,未覆盖的自由字符串参数要自己核对);**`viewpoint-debate` 的敏感内容不会被提前拦截**,会扣满 50 积分再以 `410111` 失败。
810
+ > 其余码(`999003`–`999006`、`999012`–`999016`、`100002`、`210001`、`220001`、`230001`、`240002`、`240003`)未见触发,多被上面的兜底码接管,CLI 仍内置了对应提示。⚠️ 两个需要留意的行为:**枚举值拼错和分页越界在部分端点上会报 `100005`/`100006`、在另一些端点上被静默忽略**(后者按未传该筛选条件处理,结果看着正常但范围不对——CLI 对 `--search-type`/`--rank-type`/`--file-type` 等已知枚举本地拦截,未覆盖的自由字符串参数要自己核对);**`viewpoint-debate` 的敏感内容不会被提前拦截**,会扣满 50 积分再以 `410111` 失败。
801
811
 
802
812
  ---
803
813
 
@@ -812,8 +822,9 @@ npm version patch --no-git-tag-version
812
822
  npm run prepare
813
823
  VERSION=$(node -p "require('./package.json').version")
814
824
  git commit -am "chore: release v$VERSION"
815
- git tag -a "v$VERSION" -m "v$VERSION" # 必须 annotated:--follow-tags 不推 lightweight tag
816
- git push --follow-tags
825
+ git tag -a "v$VERSION" -m "v$VERSION" # 必须 annotated
826
+ git push origin main "v$VERSION" # 显式带 tag 名,不用 --follow-tags(它不推 lightweight tag,漏掉 -a 时分支推上去而 tag 留在本地、CI 不触发)
827
+ git ls-remote --tags origin "v$VERSION" # 确认 tag 真的在远端
817
828
  ```
818
829
 
819
830
  推送 `v*` tag 后,`.github/workflows/publish.yml` 会在 GitHub-hosted runner 上使用 OIDC 发布到 `https://registry.npmjs.org/`。也可以从 GitHub Actions 页面手动运行该 workflow。
package/dist/src/cli.js CHANGED
@@ -5,29 +5,44 @@ import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
5
  import { collectKeyValue, collectList, collectNumberList, dateArg, datetimeArg, screenerExpressionFields, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseScreenerIndicators, parseSize, parseTimestamp13 } from "./core/args.js";
6
6
  import { buildIndicatorCrossSectionBody, buildIndicatorScreenerBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
7
  import { checkScreenerBindings, droppedFromMatrix, flattenCrossSection, flattenTimeSeries, isEmptyMatrix, requireIndicatorMatrix, unwrapIndicatorData } from "./core/indicatorMatrix.js";
8
+ import { callPerSecurity, estimateTradingDays } from "./core/perSecurity.js";
8
9
  import { callKlineWithSharding, isFullMarket } from "./core/quoteSharding.js";
9
10
  import { loadConfig } from "./core/config.js";
10
- import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
11
+ import { releaseClaim, resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
11
12
  import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
12
13
  import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
13
14
  import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "./core/fileParse.js";
14
- import { normalizeRows, zipFieldRow } from "./core/normalize.js";
15
+ import { assertColumnarHeader, flagMissingFields, normalizeRows, zipFieldRow } from "./core/normalize.js";
15
16
  import { parseOutputFormat } from "./core/output.js";
16
17
  import { printData } from "./core/printer.js";
17
- async function createClient() {
18
+ import { ExportSink, rowCount } from "./core/rowSink.js";
19
+ async function createClient(stream) {
18
20
  const { GangtiseClient } = await import("./core/client.js");
19
- return new GangtiseClient(loadConfig());
21
+ const sink = stream?.output && (stream.format === "jsonl" || stream.format === "csv") ? new ExportSink(stream.output, stream.format, stream.cache) : undefined;
22
+ return new GangtiseClient(loadConfig(), sink);
20
23
  }
21
24
  async function emit(options, produce, cache) {
22
25
  const format = parseOutputFormat(options.format);
23
- const client = await createClient();
24
- await printData(await produce(client), format, options.output, cache);
26
+ const client = await createClient({ format, output: options.output, cache });
27
+ try {
28
+ await printData(await produce(client), format, options.output, cache);
29
+ }
30
+ finally {
31
+ await client.rowSink?.abort();
32
+ }
25
33
  }
26
- async function withClient(fn) {
27
- await fn(await createClient());
34
+ async function withClient(a, b) {
35
+ const [stream, fn] = typeof a === "function" ? [undefined, a] : [a, b];
36
+ const client = await createClient(stream);
37
+ try {
38
+ await fn(client);
39
+ }
40
+ finally {
41
+ await client.rowSink?.abort();
42
+ }
28
43
  }
29
44
  const DEFAULT_QUOTE_LIMIT = 6000;
30
- function flagIfLimitTruncated(data, cap, label) {
45
+ function flagIfLimitTruncated(data, cap, label, rangeFlags = "--start-date/--end-date") {
31
46
  if (!data || typeof data !== "object" || Array.isArray(data))
32
47
  return;
33
48
  const rec = data;
@@ -35,7 +50,7 @@ function flagIfLimitTruncated(data, cap, label) {
35
50
  return;
36
51
  if (Array.isArray(rec.list) && rec.list.length >= cap) {
37
52
  rec.partial = true;
38
- process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). Narrow --start-date/--end-date or raise --limit (max 10000), fetching in date batches.\n`);
53
+ process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). Narrow ${rangeFlags} or raise --limit (max 10000), fetching in date batches.\n`);
39
54
  }
40
55
  }
41
56
  async function runDownload(client, endpointKey, query, options) {
@@ -46,7 +61,15 @@ async function runDownload(client, endpointKey, query, options) {
46
61
  }
47
62
  const result = await client.call(endpointKey, options.body, query);
48
63
  const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
49
- await saveDownloadResult(result, options.fallbackName, resolved ? await uniquePath(resolved) : undefined);
64
+ const target = resolved ? await uniquePath(resolved) : undefined;
65
+ try {
66
+ await saveDownloadResult(result, options.fallbackName, target);
67
+ }
68
+ catch (error) {
69
+ if (target)
70
+ await releaseClaim(target);
71
+ throw error;
72
+ }
50
73
  }
51
74
  function addDownloadCommand(parent, spec) {
52
75
  const cmd = parent.command(spec.name ?? "download").requiredOption(`${spec.idOption} <id>`);
@@ -220,10 +243,11 @@ function flagIfImplicitCapHit(data, cap, from) {
220
243
  if (!data || typeof data !== "object" || Array.isArray(data))
221
244
  return;
222
245
  const rec = data;
223
- if (!Array.isArray(rec.list) || rec.list.length < cap)
246
+ const rows = rowCount(data);
247
+ if (rows < cap)
224
248
  return;
225
249
  const total = typeof rec.total === "number" ? rec.total : undefined;
226
- if (total !== undefined && from + rec.list.length >= total)
250
+ if (total !== undefined && from + rows >= total)
227
251
  return;
228
252
  rec.partial = true;
229
253
  process.stderr.write(`[gangtise] warning: --security was the only bound, so the fetch was capped at ${cap} rows and more remain (total=${String(rec.total)}) — the filter may not have narrowed anything. Re-run with --start-date/--end-date or an explicit --size.\n`);
@@ -396,40 +420,63 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
396
420
  .action((options) => {
397
421
  checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`);
398
422
  options.security = canonicalizeMarketKeywords(options.security, Object.keys(markets));
399
- return withClient(async (client) => {
423
+ return withClient(options, async (client) => {
400
424
  const format = parseOutputFormat(options.format);
401
425
  const body = buildQuoteKlineBody(options);
402
426
  const keyword = Object.keys(markets).find((k) => isFullMarket(body, k));
403
427
  if (keyword) {
404
428
  const data = await callKlineWithSharding(client, endpointKey, body, { shardDays: markets[keyword], fullMarketValue: keyword });
429
+ flagMissingFields(data, body.fieldList, `quote ${name}`);
405
430
  await printData(data, format, options.output);
406
431
  return;
407
432
  }
408
433
  const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
434
+ const securities = body.securityList ?? [];
435
+ if (securities.length > 1 && securities.length * estimateTradingDays(body.startDate, body.endDate) > limit) {
436
+ const data = await callPerSecurity(client, endpointKey, securities, (code) => ({ ...body, securityList: [code], limit }), limit, `quote ${name}`);
437
+ flagMissingFields(data, body.fieldList, `quote ${name}`);
438
+ await printData(data, format, options.output);
439
+ return;
440
+ }
409
441
  const data = await client.call(endpointKey, { ...body, limit });
410
442
  flagIfLimitTruncated(data, limit, name);
443
+ flagMissingFields(data, body.fieldList, `quote ${name}`);
411
444
  await printData(data, format, options.output);
412
445
  });
413
446
  });
414
- addKlineCommand("day-kline", "quote.day-kline", "Security code — A-share .SH/.SZ/.BJ, HK .HK, US .O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI; or one market keyword: aShares / hkStocks / usStocks (auto-sharded by date, must be passed alone)", KLINE_MARKETS);
447
+ addKlineCommand("day-kline", "quote.day-kline", "Security code — A-share .SH/.SZ/.BJ, ETF .SH/.SZ (e.g. 512800.SH), HK .HK, US .O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); or one market keyword: aShares / hkStocks / usStocks (auto-sharded by date, must be passed alone; keywords cover stocks only — ETFs and indices must be listed by code)", KLINE_MARKETS);
415
448
  addKlineCommand("day-kline-hk", "quote.day-kline-hk", "[deprecated: use 'day-kline'] Security code (HK stock: .HK, or 'all' for full market)", LEGACY_ALL_MARKET(2));
416
449
  addKlineCommand("day-kline-us", "quote.day-kline-us", "[deprecated: use 'day-kline'] Security code (US stock: e.g. AAPL.O, or 'all' for full market)", LEGACY_ALL_MARKET(1));
417
450
  addKlineCommand("index-day-kline", "quote.index-day-kline", "[deprecated: use 'day-kline'] Index code (.SH/.SZ/.BJ, or 'all' for full market)", LEGACY_ALL_MARKET(15));
418
- quote.command("minute-kline").option("--security <code>", "Security code — A-share .SH/.SZ (SH/SZ only), exchange index .SH/.SZ, concept index .GT, industry index .CI/.SWI; no whole-market keyword").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
451
+ quote.command("minute-kline").option("--security <code>", "Security code — A-share .SH/.SZ (SH/SZ only), ETF .SH/.SZ (e.g. 512800.SH), exchange index .SH/.SZ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); repeat for several — one request each, run concurrently and merged; no whole-market keyword", collectList, []).option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(options, async (client) => {
419
452
  const format = parseOutputFormat(options.format);
420
453
  const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }) ?? DEFAULT_QUOTE_LIMIT;
421
- const data = await client.call("quote.minute-kline", { securityCode: options.security, startTime: options.startTime, endTime: options.endTime, limit, fieldList: maybeArray(options.field) });
422
- flagIfLimitTruncated(data, limit, "minute-kline");
454
+ const fieldList = maybeArray(options.field);
455
+ const securities = options.security;
456
+ if (securities.length === 0)
457
+ throw new ValidationError("--security is required (repeat it for several securities)");
458
+ const makeBody = (code) => ({ securityCode: code, startTime: options.startTime, endTime: options.endTime, limit, fieldList });
459
+ const data = securities.length === 1
460
+ ? await client.call("quote.minute-kline", makeBody(securities[0]))
461
+ : await callPerSecurity(client, "quote.minute-kline", securities, makeBody, limit, "quote minute-kline");
462
+ if (securities.length === 1)
463
+ flagIfLimitTruncated(data, limit, "minute-kline", "--start-time/--end-time");
464
+ flagMissingFields(data, fieldList, "quote minute-kline");
423
465
  await printData(data, format, options.output);
424
466
  }));
425
- quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US stocks and indices)").option("--security <code>", "Security code — stock .SH/.SZ/.BJ/.HK/.O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI; or one market keyword: aShares / hkStocks / usStocks (must be passed alone; indices have no whole-market keyword)", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
467
+ quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US stocks, ETFs, and indices incl. 20 global indices)").option("--security <code>", "Security code — stock .SH/.SZ/.BJ/.HK/.O/.N/.A, ETF .SH/.SZ (e.g. 512800.SH), exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); or one market keyword: aShares / hkStocks / usStocks (must be passed alone; keywords cover stocks only — ETFs and indices have no whole-market keyword)", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
426
468
  checkMarketKeywords(options.security, REALTIME_MARKETS, "quote realtime");
427
- return emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) }));
469
+ return emit(options, async (client) => {
470
+ const fieldList = maybeArray(options.field);
471
+ const data = await client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList });
472
+ flagMissingFields(data, fieldList, "quote realtime");
473
+ return data;
474
+ });
428
475
  });
429
476
  quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 920982.BJ), or 'aShares' for full A-share market — auto-sharded by day (repeat)", collectList, []).option("--start-date <date>", "Start date yyyy-MM-dd (default: endDate minus 1 year)", dateArg("--start-date")).option("--end-date <date>", "End date yyyy-MM-dd (default: latest trading day)", dateArg("--end-date")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000; single-security cap — aShares auto-shards by day)").option("--field <field>", "Field, e.g. mainNetInflow/largeInflow/xlargeOutflow (repeat); omit for all", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
430
477
  checkMarketKeywords(options.security, FUND_FLOW_MARKETS, "quote fund-flow");
431
478
  options.security = canonicalizeMarketKeywords(options.security, FUND_FLOW_MARKETS);
432
- return withClient(async (client) => {
479
+ return withClient(options, async (client) => {
433
480
  const format = parseOutputFormat(options.format);
434
481
  const body = {
435
482
  securityList: maybeArray(options.security),
@@ -443,12 +490,14 @@ quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").opt
443
490
  throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
444
491
  }
445
492
  const data = await callKlineWithSharding(client, "quote.fund-flow", body, { shardDays: 1, fullMarketValue: "aShares" });
493
+ flagMissingFields(data, body.fieldList, "quote fund-flow");
446
494
  await printData(data, format, options.output);
447
495
  return;
448
496
  }
449
497
  const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
450
498
  const data = await client.call("quote.fund-flow", { ...body, limit });
451
499
  flagIfLimitTruncated(data, limit, "fund-flow");
500
+ flagMissingFields(data, body.fieldList, "quote fund-flow");
452
501
  await printData(data, format, options.output);
453
502
  });
454
503
  });
@@ -469,7 +518,10 @@ addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3
469
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) })));
470
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) => {
471
520
  const format = parseOutputFormat(options.format);
472
- 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 });
473
525
  if (options.skipNull) {
474
526
  const normalized = normalizeRows(data);
475
527
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
@@ -480,6 +532,13 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
480
532
  return false;
481
533
  const r = row;
482
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;
483
542
  });
484
543
  data = { ...rec, list: filtered, total: filtered.length };
485
544
  }
@@ -585,9 +644,13 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
585
644
  }
586
645
  }));
587
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)));
588
- 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 6000 per call; 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) => {
589
649
  if (!options.security.length)
590
- throw new ValidationError("--security is required: pass one or more security codes (A-share / HK), up to 6000 per call");
650
+ throw new ValidationError(`--security is required: pass one or more security codes (A-share / HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call`);
651
+ if (options.security.length > STOCK_SUMMARY_MAX_SECURITIES) {
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.`);
653
+ }
591
654
  checkMarketKeywords(options.security, [], "ai stock-summary");
592
655
  return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
593
656
  });
@@ -630,7 +693,7 @@ vault.command("record-list").option("--from <number>", "Starting offset", "0").o
630
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" });
631
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" }));
632
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" });
633
- 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))));
634
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))));
635
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", {})));
636
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))));
@@ -651,6 +714,7 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
651
714
  let data = raw;
652
715
  if (raw && Array.isArray(raw.fieldList) && Array.isArray(raw.dataList)) {
653
716
  const fields = raw.fieldList;
717
+ assertColumnarHeader(fields, raw);
654
718
  const list = raw.dataList.map((row) => zipFieldRow(fields, row, raw));
655
719
  data = { list, total: list.length };
656
720
  }
@@ -773,7 +837,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
773
837
  throw new ConfigError(`Unknown endpoint key: ${endpointKey}`);
774
838
  }
775
839
  const format = parseOutputFormat(options.format);
776
- const client = await createClient();
840
+ const client = await createClient({ format, output: options.output });
777
841
  let body;
778
842
  if (options.body) {
779
843
  try {
@@ -797,8 +861,12 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
797
861
  if (Object.keys(options.query).length > 0) {
798
862
  throw new ValidationError(`--query is not supported for JSON endpoints (use --body '{...}'); ${endpointKey} is kind=json`);
799
863
  }
800
- const data = await client.call(endpointKey, body);
801
- await printData(data, format, options.output);
864
+ try {
865
+ await printData(await client.call(endpointKey, body), format, options.output);
866
+ }
867
+ finally {
868
+ await client.rowSink?.abort();
869
+ }
802
870
  })).addCommand(new Command("list").description("List all registered endpoint keys (for use with 'raw call')").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => printData(listEndpoints(), parseOutputFormat(options.format), options.output)));
803
871
  async function checkForUpdate(timeoutMs = 2000) {
804
872
  try {