gangtise-openapi-cli 0.38.0 → 0.40.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,14 +6,15 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
+ - **v0.40.0 — 2026-09-19**:① **自选股股票池可增删改**:新增 `vault stock-pool-create` / `stock-pool-rename` / `stock-pool-add-stock` / `stock-pool-remove-stock` / `stock-pool-delete` 五个命令,全部免费、只操作本账号数据。**这是本 CLI 仅有的写操作**——`stock-pool-delete` 会连带移除池内全部关注关系且不可恢复,必须显式加 `--yes`(不加则直接报错、不发请求;个股的投资笔记不受影响)。加 / 删自选与删池是**逐条处理**的:单条失败(如证券代码不存在)服务端仍返回成功信封、明细放在 `failList`,CLI 检测到后在 stderr 点名、标 `partial`、**退出码 3**,批量脚本按退出码判断即可。池名上限 10 个字符、不可与已有池重名(分别返回 `230007` / `230006`),每账号最多 30 个池(`230003`);证券代码大小写敏感且要带市场后缀,`600519.sh` / `700.HK` 这类会进 `failList` 而不是报错。**`raw call` 打这些端点时规则完全相同**——`raw call vault.stock-pool.delete` 一样要 `--yes`,逐条失败一样退出 3。② **`indicator time-series` 的日期轴改为自动判定**:不传 `--calendar-type` 时,CLI 先用免费的 `indicator search` 读各指标的 `parameterList`(去重后每个指标码各查一次,并发)——**全部是交易日类指标才按交易日取**(无非交易日空行,单元格也更省),只要有一个报告期类指标就按自然日取。这条很要紧:报告期末常落在非交易日(如 2024-03-31、2024-06-30 均为周日),按交易日取会让报告期类指标**整行全 `null`、退出码 0、不报错**。显式传了 `--calendar-type` 就完全按给的发。③ **`indicator` 截面 / 时序新增单次 30000 单元格上限**(截面 = 证券数 × 指标数,时序 = 序列数 × 日期数),超出报 `100006` 且不返回部分结果——按这个乘积拆批。⚠️ `--security` 传板块 ID 时服务端会展开成全部成分股,实际证券数远大于写进命令的条数。④ 🔴 **Token 缓存绑定签发它的凭证**:同一台机器上换掉 `GANGTISE_ACCESS_KEY` 后,只要上一个账号的 token 还没过期,此前会继续拿它发请求——取到的是**上一个账号**的数据;本版新增的股票池写命令若撞上,改的也会是上一个账号的池。现在凭证一换即重新登录。**`auth login` 报告的是接下来真正会用的身份**(新增 `source` 字段):设了 `GANGTISE_TOKEN` 就报那个注入的 token 并说明未登录,只有走 AK/SK 时才真的登录;返回的 `cache` 描述这次登录本身,缓存落盘失败也不会混进上一个账号。⑤ **`ai knowledge-batch --query` 整段送出**:此前按逗号拆,`--query "比较两家公司毛利率,并解释差异"` 会被拆成两个半句各查一次;多个问题仍用重复 `--query`。⑥ **异步任务的提交与查询都遵守 `--output` / `--format`**:`ai earnings-review` / `viewpoint-debate` 不加 `--wait` 的提交结果,以及 `*-check` 返回「还在生成中」时的 pending 结果,此前都直接打到 stdout——传了 `--output` 却拿到退出 0 加一个不存在的文件,而 pending 恰恰是轮询脚本最需要读的那个。⑦ 错误码:新增 `230003` / `230006` / `230007` 的处置提示;`100006` 的提示补上 EDE 单元格口径。⑧ 文档:分钟线加总对日线的口径更正——**个别历史交易日的分钟数据与日线不一致**,成交量 / 成交额统计一律以日线为准,**别把「Σ分钟 = 日线」写成校验条件**(详见 `gangtise-openapi/references/fields.md`)。
10
+
11
+ - **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` 港股均有数。分钟线加总对日线的口径见 `gangtise-openapi/references/fields.md`。
9
12
  - **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
13
  - **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
14
  - **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
- - **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
-
15
15
  ### 历史里程碑
16
16
 
17
+ - **v0.36.0**:日期写法放宽为三种「年在前」格式(「年在后」仍拒收,接口会按美式解析导致差半年),`indicator screener` 支持无日期指标。
17
18
  - **v0.29.0**:新增财报日历与 PDF 解析工具,群消息补 `quoteMsg`,并加强大整数 ID 与高积分调用防护。
18
19
  - **v0.26.0–v0.27.0**:建立高积分端点 `no-replay`、原子下载与容错分页机制,并补齐 Skill 分发和发布质量门禁。
19
20
  - **v0.22.0–v0.23.0**:统一“省略 `--size` 即拉全量”的分页语义,引入机器可识别的部分结果、Token 自愈,并完成 API 域名迁移与资金流向、机构搜索支持。
@@ -189,7 +190,7 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
189
190
  | **AI** | `knowledge-batch` | 知识库批量检索 |
190
191
  | | `knowledge-resource-download` | 知识资源下载 |
191
192
  | | `security-clue` | 个股线索 |
192
- | | `stock-summary` | 个股看点(精炼投研总结,按代码批量、单次最多 5000 个——超过时服务端整批返空,CLI 本地拦截并提示分批;仅 A 股/港股,不支持全市场) |
193
+ | | `stock-summary` | 个股看点(精炼投研总结,按代码批量、单次最多 6000 个;仅 A 股/港股,不支持全市场) |
193
194
  | | `one-pager` | 一页通 |
194
195
  | | `investment-logic` | 投资逻辑 |
195
196
  | | `peer-comparison` | 同业对比 |
@@ -205,6 +206,8 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
205
206
  | | `my-conference-list` / `my-conference-download` | 我的会议列表与下载 |
206
207
  | | `wechat-message-list` / `wechat-chatroom-list` | 群消息列表与群ID查询 |
207
208
  | | `stock-pool-list` / `stock-pool-stocks` | 自选股股票池列表与证券明细 |
209
+ | | `stock-pool-create` / `stock-pool-rename` / `stock-pool-delete` | 建池 / 改名 / 删池(删池须加 `--yes`) |
210
+ | | `stock-pool-add-stock` / `stock-pool-remove-stock` | 批量关注 / 批量取消关注个股 |
208
211
  | **Indicator** | `search` | 证券级数据指标搜索(按名称匹配,返回 indicatorCode 及可传参数 parameterList) |
209
212
  | | `cross-section` | 指标截面数据(多指标 × 多证券,单日快照;前置 `search` 拿 code) |
210
213
  | | `time-series` | 指标时间序列(多指标 × 单证券 或 单指标 × 多证券,按区间) |
@@ -262,8 +265,8 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
262
265
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
263
266
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
264
267
  - **流式输出**:`--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` 自带标记,不生成。
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 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
268
+ - **导出元信息**:`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` 自带标记,不生成。
269
+ - **自动重试**: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 下载;另加不计分但不可重复执行的 `vault stock-pool-create`,共 19 个):5xx/超时不重放,仅连接失败、429 与 token 自愈重试。**两类端点各有各的理由**:贵档是**重放会重复扣分**——服务端可能已经执行并计费,重发按次计费的再扣一次,重发按篇/按条计费的会把已交付的行再计一次;`stock-pool-create` 是**重放会把成功报成失败**——池名不允许与已有池重复,重发一个其实已经建成的请求,回来的是 `230006 股票池名称重复`。**`indicator`(EDE)端点对 `999999` 不重试**——重放一次已计费的查询没有意义(EDE 无数据不用此码,而是保留行列的占位单元格 `null`;空表另表示整轴 code 未识别或参数名写错)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
267
270
 
268
271
  <!-- no-replay-endpoints
269
272
  上面那句点名的「不重放」端点,完整清单如下(endpoint key,与 `gangtise raw list` 一致):
@@ -285,8 +288,11 @@ insight.pamirs-summary.download
285
288
  insight.summary.download
286
289
  tool.file-parse.submit
287
290
  vault.my-conference.download
291
+ vault.stock-pool.create
288
292
  -->
289
293
  - **Token 自愈**:调用返回 `0000001008` / `999002` 时自动强制刷新 Token 并重试一次。
294
+ - **Token 缓存绑定账号**:缓存记录它是为哪组凭证 + 哪个 `GANGTISE_BASE_URL` 签发的(只存不可逆的指纹,不存 key 本身)。换了 `GANGTISE_ACCESS_KEY` 再跑,即使旧 token 还没过期也会重新登录,**不会拿上一个账号的身份去发请求**——这点在股票池那五个写命令上尤其要紧。
295
+ - **`auth login` 报告的是「接下来真正会用的身份」**,返回体里的 `source` 说明它从哪来:`GANGTISE_TOKEN` 有值时是 `env-token`(那个 token 对所有命令优先,所以不联服务端、也不签发新的,并附一句提示);没有它、有 AK/SK 时是 `login`(每次都真的登录,不复用缓存);两者都没有才报错。返回的 `cache` 描述的就是这次登录的结果,缓存落盘失败也不会混进上一个账号的信息。
290
296
  - **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` 可重复,逐只并发请求后合并。
291
297
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
292
298
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
@@ -325,8 +331,8 @@ vault.my-conference.download
325
331
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
326
332
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
327
333
  - 安全上限:自动翻页最多 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` 硬失败
334
+ - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`,只有部分分片返回、合并时放不下的列为 `droppedColumns`;`quote` 系带 `--field` 而服务端没回的列为 `missingFields`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
335
+ - **`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
336
  - **分页端点返回 `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
337
  - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:分页端点的 `total` 若被服务端封顶(返回一个固定上限而非真实条数),省略 `--size` 的全量拉取会**正好取满那个上限就停、且不报任何异常**——导出的文件是截断的却看不出来。全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
332
338
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
@@ -554,7 +560,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
554
560
  gangtise ai knowledge-batch --query 新能源汽车 --resource-type 10 --resource-type 11 --top 10
555
561
  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
562
  gangtise ai one-pager --security-code 600519.SH
557
- # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 5000 个(更大的批次拆开分次调);不支持全市场关键字
563
+ # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 6000 个(更大的批次拆开分次调);不支持全市场关键字
558
564
  gangtise ai stock-summary --security 600519.SH --security 00700.HK --format json
559
565
  gangtise ai investment-logic --security-code 600519.SH
560
566
  gangtise ai peer-comparison --security-code 600519.SH
@@ -612,6 +618,15 @@ gangtise vault stock-pool-list
612
618
  gangtise vault stock-pool-stocks --pool-id 808477293
613
619
  # 查询所有股票池中的全量证券(默认行为)
614
620
  gangtise vault stock-pool-stocks
621
+
622
+ # 维护股票池:建池 → 批量关注 → 改名 → 取消关注 → 删池
623
+ # (这五个命令会改动账号数据,是本 CLI 仅有的写操作)
624
+ gangtise vault stock-pool-create --name "AI算力观察"
625
+ gangtise vault stock-pool-add-stock --pool-id 808477293 --security 600519.SH --security 000858.SZ
626
+ gangtise vault stock-pool-rename --pool-id 808477293 --name "核心持仓"
627
+ gangtise vault stock-pool-remove-stock --pool-id 808477293 --security 000858.SZ
628
+ # 删池会连带移除池内全部关注关系且不可恢复,必须显式 --yes
629
+ gangtise vault stock-pool-delete --pool-id 808477293 --yes
615
630
  ```
616
631
 
617
632
  ### Indicator(证券级数据指标 EDE)
package/dist/src/cli.js CHANGED
@@ -2,17 +2,18 @@
2
2
  import { Command, Option } from "commander";
3
3
  import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
4
  import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
- import { collectKeyValue, collectList, collectNumberList, dateArg, datetimeArg, screenerExpressionFields, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseScreenerIndicators, parseSize, parseTimestamp13 } from "./core/args.js";
5
+ import { collectKeyValue, collectList, collectNumberList, collectText, 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
8
  import { callPerSecurity, estimateTradingDays } from "./core/perSecurity.js";
9
9
  import { callKlineWithSharding, isFullMarket } from "./core/quoteSharding.js";
10
10
  import { loadConfig } from "./core/config.js";
11
11
  import { releaseClaim, resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
12
+ import { resolveCalendarType } from "./core/calendarType.js";
12
13
  import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
13
14
  import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
14
15
  import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "./core/fileParse.js";
15
- import { assertColumnarHeader, flagMissingFields, normalizeRows, zipFieldRow } from "./core/normalize.js";
16
+ import { assertColumnarHeader, flagFailedItems, flagMissingFields, normalizeRows, zipFieldRow } from "./core/normalize.js";
16
17
  import { parseOutputFormat } from "./core/output.js";
17
18
  import { printData } from "./core/printer.js";
18
19
  import { ExportSink, rowCount } from "./core/rowSink.js";
@@ -127,7 +128,13 @@ program
127
128
  .option("--format <format>", "Output format", "json")
128
129
  .action((options) => emit(options, async (client) => {
129
130
  const result = await client.login();
130
- return options.showToken ? result : { authorization: "<redacted>", cache: redactTokenCache(result.cache) };
131
+ const note = result.source === "env-token"
132
+ ? "GANGTISE_TOKEN is set, so this is the token every command will send — no login was performed. Unset it to use GANGTISE_ACCESS_KEY / GANGTISE_SECRET_KEY instead."
133
+ : undefined;
134
+ const base = { source: result.source, ...(note ? { note } : {}) };
135
+ return options.showToken
136
+ ? { ...base, authorization: result.authorization, cache: result.cache }
137
+ : { ...base, authorization: "<redacted>", cache: redactTokenCache(result.cache) };
131
138
  })))
132
139
  .addCommand(new Command("status")
133
140
  .option("--format <format>", "Output format", "json")
@@ -518,7 +525,10 @@ addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3
518
525
  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
526
  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
527
  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) });
528
+ const requested = maybeArray(options.field);
529
+ const filterFields = options.skipNull && requested ? ["value", "percentileRank"].filter((field) => !requested.includes(field)) : [];
530
+ const fieldList = requested && filterFields.length > 0 ? [...requested, ...filterFields] : requested;
531
+ 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
532
  if (options.skipNull) {
523
533
  const normalized = normalizeRows(data);
524
534
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
@@ -529,6 +539,13 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
529
539
  return false;
530
540
  const r = row;
531
541
  return r.value != null && r.percentileRank != null;
542
+ }).map((row) => {
543
+ if (filterFields.length === 0)
544
+ return row;
545
+ const r = { ...row };
546
+ for (const field of filterFields)
547
+ delete r[field];
548
+ return r;
532
549
  });
533
550
  data = { ...rec, list: filtered, total: filtered.length };
534
551
  }
@@ -544,7 +561,7 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
544
561
  }));
545
562
  program.addCommand(fundamental);
546
563
  const ai = new Command("ai").description("AI APIs");
547
- ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--end-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
564
+ ai.command("knowledge-batch").option("--query <text>", "Query text; repeat for up to 5. Each --query is taken whole — commas inside it are part of the question, not separators", collectText, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--end-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
548
565
  if (!options.query.length)
549
566
  throw new ValidationError("--query is required: pass at least one --query");
550
567
  return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time") }));
@@ -570,7 +587,7 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
570
587
  }
571
588
  if (!options.wait) {
572
589
  process.stderr.write(`Earnings review task submitted. dataId: ${dataId}\n`);
573
- process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai earnings-review-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
590
+ await printData({ dataId, status: "pending", hint: `Run 'gangtise ai earnings-review-check --data-id ${dataId}' in ~2 minutes to get results` }, format, options.output);
574
591
  return;
575
592
  }
576
593
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
@@ -621,7 +638,7 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
621
638
  }
622
639
  if (!options.wait) {
623
640
  process.stderr.write(`Viewpoint debate task submitted. dataId: ${dataId}\n`);
624
- process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai viewpoint-debate-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
641
+ await printData({ dataId, status: "pending", hint: `Run 'gangtise ai viewpoint-debate-check --data-id ${dataId}' in ~2 minutes to get results` }, format, options.output);
625
642
  return;
626
643
  }
627
644
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
@@ -634,12 +651,12 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
634
651
  }
635
652
  }));
636
653
  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) => {
654
+ const STOCK_SUMMARY_MAX_SECURITIES = 6000;
655
+ 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
656
  if (!options.security.length)
640
657
  throw new ValidationError(`--security is required: pass one or more security codes (A-share / HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call`);
641
658
  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.`);
659
+ 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
660
  }
644
661
  checkMarketKeywords(options.security, [], "ai stock-summary");
645
662
  return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
@@ -683,10 +700,36 @@ vault.command("record-list").option("--from <number>", "Starting offset", "0").o
683
700
  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
701
  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
702
  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))));
703
+ 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
704
  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
705
  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
706
  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))));
707
+ function assertConfirmed(endpointKey, confirmed, target) {
708
+ const destructive = ENDPOINTS[endpointKey]?.destructive;
709
+ if (!destructive || confirmed)
710
+ return;
711
+ throw new ValidationError(`${destructive.warning} 确认要对 ${target} 执行就加上 --yes。`);
712
+ }
713
+ vault.command("stock-pool-create").requiredOption("--name <name>", "New pool name; max 10 characters (a CJK character counts as 1) and must differ from every existing pool name. A duplicate is rejected with 230006, an over-long name with 230007. Max 30 pools per account (230003)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.create", { poolName: options.name })));
714
+ vault.command("stock-pool-rename").requiredOption("--pool-id <id>", "Pool ID from 'stock-pool-list'").requiredOption("--name <name>", "New pool name; same rules as stock-pool-create (max 10 characters, must differ from every existing pool name). Renaming a pool to its own current name succeeds").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.rename", { poolId: options.poolId, poolName: options.name })));
715
+ vault.command("stock-pool-add-stock").requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'").requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, async (client) => {
716
+ const data = await client.call("vault.stock-pool.add-stock", { poolId: options.poolId, securityCodeList: options.security });
717
+ flagFailedItems(data, "vault stock-pool-add-stock");
718
+ return data;
719
+ }));
720
+ vault.command("stock-pool-remove-stock").requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'").requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, async (client) => {
721
+ const data = await client.call("vault.stock-pool.remove-stock", { poolId: options.poolId, securityCodeList: options.security });
722
+ flagFailedItems(data, "vault stock-pool-remove-stock");
723
+ return data;
724
+ }));
725
+ vault.command("stock-pool-delete").requiredOption("--pool-id <id>", "Pool ID to delete; repeat or comma-separate for multiple", collectList).option("--yes", "Required: confirm the deletion").option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
726
+ assertConfirmed("vault.stock-pool.delete", Boolean(options.yes), options.poolId.join("、"));
727
+ await emit(options, async (client) => {
728
+ const data = await client.call("vault.stock-pool.delete", { poolIdList: options.poolId });
729
+ flagFailedItems(data, "vault stock-pool-delete");
730
+ return data;
731
+ });
732
+ });
690
733
  program.addCommand(vault);
691
734
  program.addCommand(ai);
692
735
  const alternative = new Command("alternative").description("Alternative data APIs");
@@ -760,10 +803,18 @@ indicator.command("cross-section").option("--indicator <code>", "Indicator code,
760
803
  flagDropped(rows, data, options.security, options.indicator);
761
804
  await printData(rows, format, options.output);
762
805
  }));
763
- indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday (default TD)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode/securityCode, unique & order-stable for batch mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
806
+ indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday. Omit it and the CLI picks: a free 'indicator search' reads each parameterList and sends TD only when EVERY indicator is trading-day typed, otherwise it leaves the server on ND (report-period indicators land on dates a trading calendar lacks, and TD would answer them with an all-null grid). An explicit value is sent as given, with no lookup").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode/securityCode, unique & order-stable for batch mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
764
807
  const format = parseOutputFormat(options.format);
765
808
  requireIndicatorScope(options.indicator, options.security);
766
- const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
809
+ const body = buildIndicatorTimeSeriesBody(options);
810
+ if (!body.calendarType) {
811
+ const resolved = await resolveCalendarType(client, options.indicator);
812
+ if (resolved) {
813
+ body.calendarType = resolved;
814
+ process.stderr.write(`[gangtise] note: every requested indicator takes tradeDate, so this series was fetched on the trading-day calendar (calendarType=TD) instead of the server default ND — no all-null non-trading rows, and fewer cells against the 30000 cap. Pass --calendar-type ND to override.\n`);
815
+ }
816
+ }
817
+ const raw = await client.call("indicator.time-series", body);
767
818
  const data = requireIndicatorMatrix(raw);
768
819
  const rows = flattenTimeSeries(data, options.keyBy, options.security);
769
820
  flagDropped(rows, data, options.security, options.indicator);
@@ -821,11 +872,12 @@ tool.command("file-parse-check").description("Download a finished file-parse res
821
872
  }
822
873
  }));
823
874
  program.addCommand(tool);
824
- program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
875
+ program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--yes", "Confirm an irreversible endpoint (required for the ones marked destructive)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
825
876
  const endpoint = ENDPOINTS[endpointKey];
826
877
  if (!endpoint) {
827
878
  throw new ConfigError(`Unknown endpoint key: ${endpointKey}`);
828
879
  }
880
+ assertConfirmed(endpointKey, Boolean(options.yes), endpointKey);
829
881
  const format = parseOutputFormat(options.format);
830
882
  const client = await createClient({ format, output: options.output });
831
883
  let body;
@@ -852,7 +904,10 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
852
904
  throw new ValidationError(`--query is not supported for JSON endpoints (use --body '{...}'); ${endpointKey} is kind=json`);
853
905
  }
854
906
  try {
855
- await printData(await client.call(endpointKey, body), format, options.output);
907
+ const data = await client.call(endpointKey, body);
908
+ if (endpoint.itemFailures)
909
+ flagFailedItems(data, `raw call ${endpointKey}`);
910
+ await printData(data, format, options.output);
856
911
  }
857
912
  finally {
858
913
  await client.rowSink?.abort();
@@ -8,6 +8,10 @@ export function splitCsv(value) {
8
8
  export function collectList(value, previous = []) {
9
9
  return [...previous, ...splitCsv(value)];
10
10
  }
11
+ export function collectText(value, previous = []) {
12
+ const text = value.trim();
13
+ return text ? [...previous, text] : previous;
14
+ }
11
15
  export function parseNumberOption(value, optionName, config = {}) {
12
16
  if (value === undefined || String(value).trim() === "") {
13
17
  throw new ValidationError(`Invalid ${optionName}: expected a number`);
@@ -70,5 +70,5 @@ export async function checkAsyncContent(client, getContentEndpoint, dataId, form
70
70
  if (!isAsyncPending(error))
71
71
  throw error;
72
72
  }
73
- process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
73
+ await printData({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" }, format, output);
74
74
  }
@@ -1,7 +1,10 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ConfigError } from "./errors.js";
5
+ export function credentialFingerprint(accessKey, baseUrl) {
6
+ return createHash("sha256").update(`${accessKey}\u0000${baseUrl}`).digest("hex").slice(0, 16);
7
+ }
5
8
  export async function readTokenCache(filePath) {
6
9
  try {
7
10
  const content = await fs.readFile(filePath, "utf8");
@@ -37,10 +40,13 @@ export async function writeTokenCache(filePath, cache) {
37
40
  throw error;
38
41
  }
39
42
  }
40
- export function isTokenCacheValid(cache, bufferSeconds = 300) {
43
+ export function isTokenCacheValid(cache, bufferSeconds = 300, expectedFingerprint) {
41
44
  if (!cache?.accessToken || !cache.expiresAt) {
42
45
  return false;
43
46
  }
47
+ if (expectedFingerprint !== undefined && cache.issuedFor !== expectedFingerprint) {
48
+ return false;
49
+ }
44
50
  const now = Math.floor(Date.now() / 1000);
45
51
  return cache.expiresAt - bufferSeconds > now;
46
52
  }
@@ -0,0 +1,44 @@
1
+ import { unwrapIndicatorData } from "./indicatorMatrix.js";
2
+ import { runWithConcurrency } from "./transport.js";
3
+ const TRADE_DATE_KEY = "tradeDate";
4
+ const PERIOD_DATE_KEYS = ["reportDate", "fiscalYear"];
5
+ const PROBE_CONCURRENCY = 5;
6
+ export async function resolveCalendarType(client, indicators) {
7
+ const codes = [...new Set(indicators.filter((code) => typeof code === "string" && code.length > 0))];
8
+ if (codes.length === 0)
9
+ return undefined;
10
+ const verdicts = await runWithConcurrency(codes, PROBE_CONCURRENCY, (code) => isTradingDayTyped(client, code));
11
+ return verdicts.every((verdict) => verdict === true) ? "TD" : undefined;
12
+ }
13
+ async function isTradingDayTyped(client, code) {
14
+ let entry;
15
+ try {
16
+ entry = await findIndicator(client, code);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ if (!entry)
22
+ return false;
23
+ const keys = paramKeys(entry.parameterList);
24
+ if (keys.length === 0)
25
+ return false;
26
+ if (keys.some((key) => PERIOD_DATE_KEYS.includes(key)))
27
+ return false;
28
+ return keys.includes(TRADE_DATE_KEY);
29
+ }
30
+ async function findIndicator(client, code) {
31
+ const raw = await client.call("indicator.search", { keyword: code, limit: 100 });
32
+ const data = unwrapIndicatorData(raw);
33
+ const list = Array.isArray(data) ? data : data?.list;
34
+ if (!Array.isArray(list))
35
+ return undefined;
36
+ return list.find((item) => !!item && typeof item === "object" && item.indicatorCode === code);
37
+ }
38
+ function paramKeys(parameterList) {
39
+ if (!Array.isArray(parameterList))
40
+ return [];
41
+ return parameterList
42
+ .map((param) => (param && typeof param === "object" ? param.paramKey : undefined))
43
+ .filter((key) => typeof key === "string");
44
+ }
@@ -3,10 +3,11 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { FormData, request } from "undici";
6
- import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
6
+ import { credentialFingerprint, isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
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"]);
@@ -32,11 +33,12 @@ export class GangtiseClient {
32
33
  return normalizeToken(this.config.token);
33
34
  }
34
35
  if (!forceRefresh) {
35
- if (isTokenCacheValid(this.memoCache)) {
36
+ const expected = this.config.accessKey ? credentialFingerprint(this.config.accessKey, this.config.baseUrl) : undefined;
37
+ if (isTokenCacheValid(this.memoCache, undefined, expected)) {
36
38
  return normalizeToken(this.memoCache.accessToken);
37
39
  }
38
40
  const cache = await readTokenCache(this.config.tokenCachePath);
39
- if (isTokenCacheValid(cache)) {
41
+ if (isTokenCacheValid(cache, undefined, expected)) {
40
42
  this.memoCache = cache;
41
43
  return normalizeToken(cache.accessToken);
42
44
  }
@@ -58,7 +60,13 @@ export class GangtiseClient {
58
60
  const accessToken = normalizeToken(envelope.accessToken);
59
61
  const expiresIn = Number.isFinite(envelope.expiresIn) ? envelope.expiresIn : 0;
60
62
  const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
61
- const cache = { ...envelope, accessToken, expiresIn, expiresAt };
63
+ const cache = {
64
+ ...envelope,
65
+ accessToken,
66
+ expiresIn,
67
+ expiresAt,
68
+ issuedFor: credentialFingerprint(credentials.accessKey, this.config.baseUrl),
69
+ };
62
70
  this.memoCache = cache;
63
71
  try {
64
72
  await writeTokenCache(this.config.tokenCachePath, cache);
@@ -296,12 +304,11 @@ export class GangtiseClient {
296
304
  out.totalCapped = true;
297
305
  }
298
306
  async login() {
299
- const authorization = await this.getAuthorizationHeader();
300
- const cache = await readTokenCache(this.config.tokenCachePath);
301
- return {
302
- authorization,
303
- cache,
304
- };
307
+ if (this.config.token && !this.envTokenInvalidated) {
308
+ return { authorization: normalizeToken(this.config.token), cache: null, source: "env-token" };
309
+ }
310
+ const authorization = await this.getAuthorizationHeader(true);
311
+ return { authorization, cache: this.memoCache ?? null, source: "login" };
305
312
  }
306
313
  async requestJson(endpoint, body, useAuth = true) {
307
314
  if (endpoint.path.startsWith('/guide/')) {
@@ -504,7 +511,7 @@ export class GangtiseClient {
504
511
  }
505
512
  if (options?.streamTo) {
506
513
  await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
507
- const partPath = `${options.streamTo}.part`;
514
+ const partPath = stagingPath(options.streamTo);
508
515
  try {
509
516
  await pipeline(response.body, createWriteStream(partPath));
510
517
  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 = {
@@ -589,6 +589,41 @@ const ENDPOINT_DEFS = {
589
589
  kind: "json",
590
590
  description: "List securities in stock pool(s)",
591
591
  },
592
+ "vault.stock-pool.create": {
593
+ method: "POST",
594
+ path: "/application/open-vault/stock-pool/createPool",
595
+ kind: "json",
596
+ retry: "no-replay",
597
+ description: "Create a stock pool",
598
+ },
599
+ "vault.stock-pool.delete": {
600
+ method: "POST",
601
+ path: "/application/open-vault/stock-pool/deletePool",
602
+ kind: "json",
603
+ destructive: { warning: "删除股票池会同时移除池内全部证券的关注关系,且不可恢复(投资笔记不受影响);先用 'gangtise vault stock-pool-list' 核对 ID 对应的池名。" },
604
+ itemFailures: true,
605
+ description: "Delete stock pools (removes every watch relation inside them)",
606
+ },
607
+ "vault.stock-pool.rename": {
608
+ method: "POST",
609
+ path: "/application/open-vault/stock-pool/updatePool",
610
+ kind: "json",
611
+ description: "Rename a stock pool",
612
+ },
613
+ "vault.stock-pool.add-stock": {
614
+ method: "POST",
615
+ path: "/application/open-vault/stock-pool/addStock",
616
+ kind: "json",
617
+ itemFailures: true,
618
+ description: "Add securities to a stock pool",
619
+ },
620
+ "vault.stock-pool.remove-stock": {
621
+ method: "POST",
622
+ path: "/application/open-vault/stock-pool/deleteStock",
623
+ kind: "json",
624
+ itemFailures: true,
625
+ description: "Remove securities from a stock pool",
626
+ },
592
627
  "alternative.edb-search": {
593
628
  method: "POST",
594
629
  path: "/application/open-alternative/EDB/search",
@@ -48,7 +48,7 @@ const ERROR_HINTS = {
48
48
  "100003": "msg 已指明字段名或取值范围时直接按 msg 改;msg 只说「参数值非法」时多为枚举参数拼写错误(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
49
49
  "100004": "检查 --size / --from 是否为非负数且未超单页上限。",
50
50
  "100005": "对照命令 --help 列出的合法取值检查。",
51
- "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守)。",
51
+ "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑。",
52
52
  "110001": "看参数名:`--*-date` 用 YYYY-MM-DD,`--*-time` 用 \"YYYY-MM-DD HH:mm:ss\"(`ai knowledge-batch` 的 --start-time/--end-time 收时间戳或 datetime,CLI 统一转 13 位毫秒)。",
53
53
  "110002": "起始晚于结束——检查 --start-date/--end-date 或 --start-time/--end-time 的先后。",
54
54
  "110003": "查询时间超出本账号的数据权限范围——把日期移进范围内(整个区间都早于下界时缩短窗口无用),或联系客户经理开通更长历史。",
@@ -64,6 +64,9 @@ const ERROR_HINTS = {
64
64
  "220001": "改用 list 取正文摘要。",
65
65
  "230001": "只有自己上传的文件可下载。",
66
66
  "230002": "微信账号未绑定——群消息类接口要求先在 Gangtise 端绑定并激活群消息助理、且助理已入群。",
67
+ "230003": "股票池数量已达账号上限(30 个)——先用 `vault stock-pool-delete` 删掉不用的池再建。",
68
+ "230006": "池名与该账号已有的池重复——先用 `vault stock-pool-list` 看现有名称,换一个再建/改名。",
69
+ "230007": "池名超过 10 个字符(按字符计,中文算 1 个)——缩短后重试。终端里建的老池可以更长,OpenAPI 建池不行。",
67
70
  "240001": "换更早的 --period(如 2025q3 → 2025interim)。",
68
71
  "240002": "改述后重新提交。",
69
72
  "240003": "对照命令 --help 检查取值。",