gangtise-openapi-cli 0.33.0 → 0.34.1

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,8 +6,10 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
+ - **v0.34.1 — 2026-08-15**:EDE 报告期类指标传错日期参数时,报错里直接给出该改的 CLI 写法(此前只有服务端那句「缺少必填参数 reportDate」,要自己推断该用 `--indicator-param`);该提示对 `100001` / `100003` 两个错误码都生效,并注明少数指标两个日期都要、另有指标要 `fiscalYear`,一律以 `indicator search` 的 `parameterList` 为准。另修复下载文件名缓存在并发写入下可能丢条目的问题(单进程使用不受影响)。
10
+ - **v0.34.0 — 2026-08-15**:跟进 2026-08-14 服务端更新。🔴 **破坏性**:`quote day-kline --security all` 已失效(服务端停止支持),改用 `aShares` / `hkStocks` / `usStocks`,且市场关键字必须单独传(不能与证券代码或另一个关键字混填)——这两种写法 CLI 都会在发请求前报错并指出正确写法;`ai stock-summary` 同理不再接受市场关键字。`day-kline` 现覆盖 A股/港股/美股个股与交易所/概念/行业指数(可混查),三个旧命令 `day-kline-hk`/`day-kline-us`/`index-day-kline` 标记为已下线;`minute-kline` 支持指数。**另两处影响取数完整性的修复**:`quote fund-flow` 把市场关键字与证券代码混填时,此前会只返回那几个代码的数据且不报错("全市场 + 这只"静默变成"只有这只"),现改为本地拦截;`quote index-day-kline --security all` 跨 30 天以上时分片过宽会撞行数上限被截断(标 `partial` + 退出 3),现已调细粒度,同区间可完整取回。另:三大报表新增 `earliestAnncDate`(做时点对齐用它,不要用 `announcementDate`),EDE 报告期类指标改为必须显式传 `reportDate`,错误码提示按新行为更新。
9
11
  - **v0.33.0 — 2026-08-09**:四处行为变更,都是把「静默给出看着正常的错结果」改成显式失败——分页端点返回异形首包(含 `data: null`)改退出码 3;`total` 被服务端封顶时探测并标 `totalCapped` + 退出 3(三个 opinion 端点的 `total` 恒为 10000 而实际远不止,全量导出此前会被静默截断);空结果不再在 stdout 留空行、`null` 不再被渲染成一条记录。另补多处帮助文案与 EDE 占位值(个别指标填 `0` 而非 `null`)的说明。
10
- - **v0.32.0 — 2026-08-08**:新增帕米尔专家纪要列表与下载;跟进 2026-08-07 服务端修复——EDE 缺数据不再整列/整行消失(退出码 3 现在专指「代码没被识别」),条件选股重复指标的 `unreliable` 告警随之移除;`--search-type`/`--rank-type`/`--file-type` 等枚举改为本地拦截(此前传非法值不会报错,会拿到未过滤的全量)。
12
+ - **v0.32.0 — 2026-08-08**:新增帕米尔专家纪要列表与下载;跟进 2026-08-07 服务端更新——EDE 缺数据不再整列/整行消失(退出码 3 现在专指「代码没被识别」),条件选股重复指标的 `unreliable` 告警随之移除;`--search-type`/`--rank-type`/`--file-type` 等枚举改为本地拦截(此前传非法值不会报错,会拿到未过滤的全量)。
11
13
  - **v0.31.0 — 2026-08-03**:修复 v0.30.1 的矩阵维度校验误杀「单指标 × 板块 ID」时序查询的回归;EDE 结果不完整或不可信时改以退出码 3 标记(`partial` / `unreliable`)。
12
14
  - **v0.30.1 — 2026-08-02**:修复 `sDate` 吞掉查询日期导致的区间指标静默错数,让条件选股的文本筛选真正可用,并在服务端整行/整列丢数据时给出警告。
13
15
  - **v0.30.0 — 2026-08-02**:适配 EDE 接口重构(`universe` 取代 `securityCodeList`、截面矩阵转置、指标元数据结构化),新增 `indicator screener` 条件选股,并修正复权参数名。
@@ -170,9 +172,10 @@ 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` / `day-kline-hk` / `day-kline-us` | A股/港股/美股历史日K线 |
174
- | | `index-day-kline` | 沪深京指数日K线 |
175
- | | `minute-kline` | A股分钟K线 |
175
+ | **Quote** | `day-kline` | 历史日K线——A股/港股/美股个股 + 交易所/概念/行业指数,可混查 |
176
+ | | `day-kline-hk` / `day-kline-us` | ⚠️ 已下线,能力并入 `day-kline`(接口仍可调,但不校验证券代码) |
177
+ | | `index-day-kline` | ⚠️ 已下线,能力并入 `day-kline`;但仍是取「全部沪深京指数」(`--security all`)和拿指数名称(`securityName`)的唯一方式 |
178
+ | | `minute-kline` | 分钟K线——沪深A股 + 各类指数(一次一只) |
176
179
  | | `realtime` | 个股实时行情快照(A股/港股/美股) |
177
180
  | | `fund-flow` | A股个股日资金流向(沪深京;小/中/大/特大单 + 主力净流入) |
178
181
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
@@ -186,7 +189,7 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
186
189
  | **AI** | `knowledge-batch` | 知识库批量检索 |
187
190
  | | `knowledge-resource-download` | 知识资源下载 |
188
191
  | | `security-clue` | 个股线索 |
189
- | | `stock-summary` | 个股看点(精炼投研总结,按代码或全市场;仅 A 股/港股) |
192
+ | | `stock-summary` | 个股看点(精炼投研总结,按代码批量、单次最多 6000 个;仅 A 股/港股,不支持全市场) |
190
193
  | | `one-pager` | 一页通 |
191
194
  | | `investment-logic` | 投资逻辑 |
192
195
  | | `peer-comparison` | 同业对比 |
@@ -261,7 +264,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
261
264
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
262
265
  - **自动重试**: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`、个别如 `is_dnrpnp` 是 `0`),空表另表示整轴 code 未识别)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
263
266
  - **Token 自愈**:调用返回 `0000001008` / `999002` 时自动强制刷新 Token 并重试一次。
264
- - **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
267
+ - **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 截断。
265
268
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
266
269
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
267
270
 
@@ -300,7 +303,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
300
303
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
301
304
  - 安全上限:自动翻页最多 1000 页,防止异常循环
302
305
  - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
303
- - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。**v0.32.0 起这个信号的含义变窄了**——2026-08-07 服务端改为给缺数据补占位单元格(行列都保留),所以整列/整行消失现在只发生在**服务端解析不了那个 code** 时(指标码拼错,或证券后缀错,如美股写成 `AAPL.US` 而非 `AAPL.O`)。真实的无数据/无覆盖是占位单元格 + 退出码 0。🔴 **占位值不统一**:多数指标是 `null`,个别(如 `is_dnrpnp`)是 `0`,而 `0` 会穿过比较与聚合——别把它当真值,详见 skill 的 `references/commands/indicator.md`。⚠️ **这个检测需要同批里有一个能解析的对照物**:拼错的 code 与正确的 code 混在一批才会标 partial;**整个轴都写错时(如只查一个拼错的指标)响应是空表、退出码 0**,只有 stderr 提示——空表拿到手必须先核对代码拼写与后缀。同版本移除了条件选股重复指标的 `unreliable` / `duplicatedIndicators` 标记(服务端已修复,继续告警会是误报)。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。⚠️ 这一档以「服务端返回了命中行」为前提;**零命中时一律退出码 0**(没有行需要被质疑),所以空集要先核对指标码拼写,不能直接当成「无标的符合条件」。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整/`1` 硬失败
306
+ - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。**2026-08-15 起这个分支基本收不到样本**——服务端现在对解析不了的代码直接报 `100003` 并点名是哪个(指标码拼错 →「指标 xxx 不存在」;证券后缀错,如美股写成 `AAPL.US` 而非 `AAPL.O` →「xxx 不是有效证券或者板块ID」),**无论同批有没有正确的代码都会报**,CLI 相应退出 1。真实的无数据/无覆盖仍是占位单元格 + 退出码 0。🔴 **占位值不统一**:多数指标是 `null`,个别(如 `is_dnrpnp`)是 `0`,而 `0` 会穿过比较与聚合——别把它当真值,详见 skill 的 `references/commands/indicator.md`。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。⚠️ 这一档以「服务端返回了命中行」为前提;**零命中时一律退出码 0**(没有行需要被质疑),所以空集不能直接当成「无标的符合条件」——头号嫌疑是某个变量拿到的是占位 `0`。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整/`1` 硬失败
304
307
  - **分页端点返回 `null` 也退出 3**:分页端点本该返回 `{total, list}`,真实的空结果是 `{total: 0, list: []}`。若响应体是 `null`(已知一例:`insight foreign-opinion` / `independent-opinion` 传 `--industry`),CLI 在 stderr 告警并**退出码 3**——只给告警的话,脚本无法区分「这个筛选确实没命中」和「这个筛选没生效」。机器格式(jsonl/csv)此时 **stdout 不输出任何字节**(不是空行),`--format json` 仍忠实打印 `null`。⚠️ 带 `--output` 时文件仍会被创建:csv 会写入 3 字节 UTF-8 BOM(Excel 兼容用),jsonl 为 0 字节——**按文件大小判空的脚本要留意 csv 的这 3 个字节**。
305
308
  - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:`insight opinion` / `foreign-opinion` / `independent-opinion` 三个端点的 `total` 恒为 `10000`,而实际记录远不止(把 `from` 加到远超该值仍能取到真实记录)。省略 `--size` 的全量拉取本来会**正好取满 10000 条就停、且不报任何异常**——导出的文件是截断的却看不出来。现在全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
306
309
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
@@ -436,16 +439,15 @@ gangtise reference sector-constituents --sector-id 1000001005 --format json
436
439
  gangtise quote day-kline --security 600519.SH --start-date 2026-03-01 --end-date 2026-03-31
437
440
  # 查最近/最新 K 线建议显式传 --start-date/--end-date;只传 --limit 会截取查询窗口开头,不等于最近N条
438
441
  gangtise quote day-kline --format json
439
- # 全市场查询(--security all)
440
- gangtise quote day-kline --security all --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
441
- # 港股日K线
442
- gangtise quote day-kline-hk --security 00700.HK --start-date 2026-03-01 --end-date 2026-03-31
443
- # 港股全市场
444
- gangtise quote day-kline-hk --security all --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
445
- # 美股日K线(NASDAQ/NYSE/AMEX,历史)
446
- gangtise quote day-kline-us --security AAPL.O --security MSFT.O --start-date 2026-04-22 --end-date 2026-05-22 --field tradeDate --field open --field close --field volume
447
- # 美股全市场(自动分片)
448
- gangtise quote day-kline-us --security all --start-date 2026-04-01 --end-date 2026-04-02 --field securityCode --field close --format json
442
+ # 全市场查询:关键字是 aShares / hkStocks / usStocks,必须单独传(旧的 --security all 已不再支持)
443
+ gangtise quote day-kline --security aShares --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
444
+ # 港股 / 美股 / 指数都走同一个 day-kline,可混着传
445
+ gangtise quote day-kline --security 00700.HK --security AAPL.O --start-date 2026-03-01 --end-date 2026-03-31
446
+ gangtise quote day-kline --security 000001.SH --security 880134.GT --security 821031.SWI --start-date 2026-03-01 --end-date 2026-03-31
447
+ # 港股全市场(自动按 2 天/片分片)
448
+ gangtise quote day-kline --security hkStocks --start-date 2026-04-01 --end-date 2026-04-10 --format json
449
+ # 美股全市场(自动按 1 天/片分片)
450
+ gangtise quote day-kline --security usStocks --start-date 2026-04-01 --end-date 2026-04-02 --field securityCode --field close --format json
449
451
  # 沪深京指数日K线
450
452
  gangtise quote index-day-kline --security 000001.SH --security 399001.SZ --start-date 2024-05-01 --end-date 2024-05-20 --field securityCode --field tradeDate --field close --field volume
451
453
  # A股分钟K线
@@ -607,7 +609,7 @@ gangtise indicator screener --indicator F1:pty_op_scope \
607
609
  --date 2026-07-31 --format table
608
610
 
609
611
  # 复权 / 指标专属参数用 --indicator-param "code:key=value"
610
- # ⚠️ 参数名必须以 search 的 parameterList 为准:写错名不会报错,会按默认值取数,结果看着正常但口径不对
612
+ # ⚠️ 参数名必须以 search 的 parameterList 为准;写错名会报 100003 并指出是哪个指标的哪个参数
611
613
  gangtise indicator cross-section --indicator qte_close --security 600519.SH \
612
614
  --date 2024-01-02 --indicator-param "qte_close:adjustType=3" # 1不复权/2前复权/3后复权/4定点
613
615
  # 不复权 1685.01 → 前复权 1531.225 → 后复权 13609.6168(前复权在最新交易日等于不复权,验证要用历史日)
@@ -725,14 +727,14 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
725
727
  | `120001` | 证券代码无效(用 `reference securities-search` 确认代码与后缀) |
726
728
  | `130001` | 数据未找到或无指标权限——**取代旧 `410004`** |
727
729
  | `130002` | 资源不存在——**下载类的兜底码**,`--report-id` 不存在 / 非数字 / `--file-type` 非法都归这里(取代旧 `430004`) |
728
- | `410110` / `410111` | 异步任务生成中(继续轮询)/ 生成失败(终态)——**实测服务端仍发这两个旧码**;新码 `140001`/`140002`,CLI 两代都认 |
730
+ | `410110` / `410111` | 异步任务生成中(继续轮询)/ 生成失败(终态)——异步端点当前返回的是这两个码;新码为 `140001`/`140002`,CLI 两代都认 |
729
731
  | `240001` | 财报期未披露或超出查询期(`earnings-review` 提交阶段即报,不扣积分) |
730
732
  | `250001` | 不支持该数据源(`knowledge-resource-download` 需正确的 `resourceType + sourceId` 组合)——**取代旧 `433007`** |
731
733
  | `900002` | 请求方法不正确(服务端 msg 为「请求类型有误」,HTTP 405) |
732
734
 
733
735
  > **关于这次错误码重排**:服务端 2026-07-17 重排了 41 个公开码(三层:`999xxx` 服务统一层 / `1xxxxx` 业务通用层 / `2xxxxx` 接口专有层),信封新增 `errorType` 和 `traceId`。2026-07-20 逐码实测发现**迁移是按「错误处理层」而非按业务模块进行的**:同一个接口内,参数校验层与路由层已发新码,方法路由层、token 过滤器、以及异步生成状态仍发旧码。新码信封 `code` 是 JSON 数字且带 `errorType`,旧码是字符串且没有——但这判断的是单条错误路径,不是整个接口;CLI 对两代都能识别。报错行会带 `[trace <id>]`,**报障时请带上它**。
734
736
  >
735
- > 其余码(`999003`–`999006`、`999012`–`999016`、`100002`、`100004`、`100005`、`130003`–`130005`、`210001`、`220001`、`230001`、`240002`、`240003`)在实测中未触发到,多被上面的兜底码接管,CLI 仍内置了对应提示。⚠️ `110003`(超出时间范围限制)此前也列在这里,**2026-08-08 实测已确认可触发**——凡超出账号数据权限范围的查询都返回它(如 `fundamental income-statement --fiscal-year 2015`)。两个实测坑:**枚举值拼错和分页越界服务端不报错**(静默忽略该筛选条件,v0.32.0 起 CLI 对 `--search-type`/`--rank-type`/`--file-type` 等已知枚举本地拦截);**`viewpoint-debate` 的敏感内容不会被提前拦截**,会扣满 50 积分再以 `410111` 失败。
737
+ > 其余码(`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` 失败。
736
738
 
737
739
  ---
738
740
 
package/dist/src/cli.js CHANGED
@@ -5,7 +5,7 @@ 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 { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
8
+ import { callKlineWithSharding, isFullMarket } from "./core/quoteSharding.js";
9
9
  import { loadConfig } from "./core/config.js";
10
10
  import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
11
11
  import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
@@ -441,7 +441,70 @@ insight.addCommand(qa);
441
441
  insight.addCommand(reportImage);
442
442
  program.addCommand(insight);
443
443
  const quote = new Command("quote").description("Quote APIs");
444
- const addKlineCommand = (name, endpointKey, securityHelp, shardDays) => quote.command(name)
444
+ const KLINE_MARKETS = { aShares: 1, hkStocks: 2, usStocks: 1 };
445
+ const LEGACY_ALL_MARKET = (shardDays) => ({ all: shardDays });
446
+ /** Realtime takes the same keywords but returns one snapshot per security, so there is
447
+ * nothing to shard and it only needs the accepted-keyword list. */
448
+ const REALTIME_MARKETS = ["aShares", "hkStocks", "usStocks"];
449
+ /** fund-flow is A-share only, so `aShares` is its sole whole-market keyword. */
450
+ const FUND_FLOW_MARKETS = ["aShares"];
451
+ /** Every keyword the quote APIs have ever taken, including ones a given command no
452
+ * longer accepts. What an unrecognised keyword does depends on the endpoint, and BOTH
453
+ * outcomes are worth a local error: the unified `day-kline` / `realtime` / `fund-flow`
454
+ * answer `120001` "invalid security code" (which sends the user hunting for a typo in a
455
+ * code that is fine), while the menu-retired per-market endpoints answer `code=000000`
456
+ * with `total: 0` — a silent empty result indistinguishable from "no data".
457
+ *
458
+ * Compared lower-cased, and 🔴 **that folding is load-bearing, not tidiness** — the API's
459
+ * own case handling differs BY ENDPOINT (probed 2026-08-15, all six):
460
+ *
461
+ * folds case: day-kline (aShares/…), realtime, day-kline-hk/-us/index (all)
462
+ * case-SENSITIVE: fund-flow — only the literal `aShares` works; `ashares` / `ASHARES`
463
+ * come back as `120001 非有效A股`
464
+ *
465
+ * So on five endpoints canonicalising merely keeps our shard lookup in step with the
466
+ * server (drop it and a case variant degrades to an unsharded 6000-row request), but on
467
+ * `fund-flow` it is the ONLY reason `--security ashares` works at all. Removing
468
+ * `canonicalizeMarketKeywords` would turn that from a working query into a hard error —
469
+ * see the fund-flow case test, which exists to pin exactly that.
470
+ *
471
+ * ⚠️ `all` collides with a real ticker root (`ALL` is Allstate on the NYSE), so a bare
472
+ * `--security ALL` fetches the whole US market instead of that stock. That resolution
473
+ * happens on the SERVER (`ALL` / `All` / `all` are equivalent to it on all three retired
474
+ * endpoints), so matching case-sensitively here would not prevent it — it would only stop
475
+ * us from sharding a request the server treats as whole-market anyway, turning a complete
476
+ * result into a 6000-row truncation. The fix for that user is the suffixed `ALL.N`.
477
+ *
478
+ * Unknown keywords are deliberately NOT rejected — this is a known-keyword list, so a
479
+ * future server-side addition degrades to "unsharded" rather than being refused outright
480
+ * (fail-open, no enum drift). */
481
+ const MARKET_KEYWORDS = new Set(["all", "ashares", "hkstocks", "usstocks"]);
482
+ const matchesMarketKeyword = (value, keyword) => value.toLowerCase() === keyword.toLowerCase();
483
+ const checkMarketKeywords = (securities, accepted, command) => {
484
+ const used = securities.filter((s) => MARKET_KEYWORDS.has(s.toLowerCase()));
485
+ if (used.length === 0)
486
+ return;
487
+ // Report an unsupported keyword before the alone-ness rule: when both are wrong, the
488
+ // keyword itself is the thing the user has to change.
489
+ const unsupported = used.filter((k) => !accepted.some((a) => matchesMarketKeyword(k, a)));
490
+ if (unsupported.length > 0) {
491
+ throw new ValidationError(accepted.length > 0
492
+ ? `${command}: '${unsupported[0]}' is not a whole-market keyword for this command — use ${accepted.join(" / ")}`
493
+ : `${command}: this command takes explicit security codes only — '${unsupported[0]}' and other whole-market keywords are not supported`);
494
+ }
495
+ // The API rejects a keyword sent alongside security codes or a second keyword, again
496
+ // as a bare 120001 that points at the codes rather than at the combination. On
497
+ // `fund-flow` it is worse than a rejection: the keyword is silently dropped and only
498
+ // the explicit codes come back, exit 0.
499
+ if (securities.length > 1) {
500
+ throw new ValidationError(`${command}: a market keyword must be passed alone, got '${securities.join(", ")}' — the API rejects it mixed with security codes or another keyword`);
501
+ }
502
+ };
503
+ /** Fold a user-typed keyword back to the spelling the sharding lookup expects, so a case
504
+ * variant reaches the same code path as the canonical form. Non-keywords pass through
505
+ * untouched. */
506
+ const canonicalizeMarketKeywords = (securities, accepted) => securities.map((s) => accepted.find((a) => matchesMarketKeyword(s, a)) ?? s);
507
+ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.command(name)
445
508
  .option("--security <code>", securityHelp, collectList, [])
446
509
  .option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date"))
447
510
  .option("--end-date <date>", "End date (default: latest)", dateArg("--end-date"))
@@ -449,63 +512,93 @@ const addKlineCommand = (name, endpointKey, securityHelp, shardDays) => quote.co
449
512
  .option("--field <field>", "Field", collectList, [])
450
513
  .option("--format <format>", "Output format", "table")
451
514
  .option("--output <path>")
452
- .action((options) => withClient(async (client) => {
453
- const format = parseOutputFormat(options.format);
454
- const body = buildQuoteKlineBody(options);
455
- if (isAllMarket(body)) {
456
- // `--security all` is date-sharded: callKlineWithSharding lifts the limit to the
457
- // API max and owns completeness (partial / failedShards), so leave `limit` unset
458
- // and skip the single-request truncation guard.
459
- const data = await callKlineWithSharding(client, endpointKey, body, { shardDays });
515
+ .action((options) => {
516
+ // Validate BEFORE withClient: createClient() logs in when no token is cached, so a
517
+ // check inside the callback would spend a request to then fail locally anyway.
518
+ checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`);
519
+ options.security = canonicalizeMarketKeywords(options.security, Object.keys(markets));
520
+ return withClient(async (client) => {
521
+ const format = parseOutputFormat(options.format);
522
+ const body = buildQuoteKlineBody(options);
523
+ // Each market shards at its own granularity, so resolve which keyword was asked
524
+ // for before picking shardDays — a whole-market HK pull tolerates 2-day windows
525
+ // where A-share and US pulls need one day each.
526
+ const keyword = Object.keys(markets).find((k) => isFullMarket(body, k));
527
+ if (keyword) {
528
+ // A whole-market query is date-sharded: callKlineWithSharding lifts the limit to
529
+ // the API max and owns completeness (partial / failedShards), so leave `limit`
530
+ // unset and skip the single-request truncation guard.
531
+ const data = await callKlineWithSharding(client, endpointKey, body, { shardDays: markets[keyword], fullMarketValue: keyword });
532
+ await printData(data, format, options.output);
533
+ return;
534
+ }
535
+ // Explicit securities go out as one request: pin the limit to the known default so
536
+ // the sent limit and the truncation cap are the same number by construction.
537
+ const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
538
+ // (Nothing to shard: sharding only ever applied to whole-market queries, so this
539
+ // is exactly the passthrough callKlineWithSharding would have done.)
540
+ const data = await client.call(endpointKey, { ...body, limit });
541
+ flagIfLimitTruncated(data, limit, name);
460
542
  await printData(data, format, options.output);
461
- return;
462
- }
463
- // Explicit securities go out as one request: pin the limit to the known default so
464
- // the sent limit and the truncation cap are the same number by construction.
465
- const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
466
- const data = await callKlineWithSharding(client, endpointKey, { ...body, limit }, { shardDays });
467
- flagIfLimitTruncated(data, limit, name);
468
- await printData(data, format, options.output);
469
- }));
470
- addKlineCommand("day-kline", "quote.day-kline", "Security code (A-share: .SH/.SZ/.BJ, or 'all' for full market)", 1);
471
- addKlineCommand("day-kline-hk", "quote.day-kline-hk", "Security code (HK stock: .HK, or 'all' for full market)", 2);
472
- addKlineCommand("day-kline-us", "quote.day-kline-us", "Security code (US stock: e.g. AAPL.O, or 'all' for full market)", 1);
473
- addKlineCommand("index-day-kline", "quote.index-day-kline", "Index code (.SH/.SZ/.BJ, or 'all' for full market)", 30);
474
- quote.command("minute-kline").option("--security <code>", "Security code (A-share only: .SH/.SZ/.BJ)").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) => {
543
+ });
544
+ });
545
+ 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);
546
+ 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));
547
+ 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));
548
+ // 15 days, not the historical 30: ~531 index rows per trading day x ~11 trading days in a
549
+ // 15-day window is ~5.8K, while a 30-day window is ~11.7K — every shard silently maxed out
550
+ // at the 10K cap and lost ~11% of the range (it surfaced as exit 3 + truncatedShards, but
551
+ // the split was never sized to avoid it in the first place).
552
+ 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));
553
+ 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) => {
475
554
  const format = parseOutputFormat(options.format);
476
555
  const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }) ?? DEFAULT_QUOTE_LIMIT;
477
556
  const data = await client.call("quote.minute-kline", { securityCode: options.security, startTime: options.startTime, endTime: options.endTime, limit, fieldList: maybeArray(options.field) });
478
557
  flagIfLimitTruncated(data, limit, "minute-kline");
479
558
  await printData(data, format, options.output);
480
559
  }));
481
- quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US)").option("--security <code>", "Security code (e.g. 600519.SH / 00700.HK / AAPL.O), or market keyword: aShares / hkStocks / usStocks", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) })));
482
- quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 872931.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) => withClient(async (client) => {
483
- const format = parseOutputFormat(options.format);
484
- const body = {
485
- securityList: maybeArray(options.security),
486
- startDate: options.startDate,
487
- endDate: options.endDate,
488
- limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }),
489
- fieldList: maybeArray(options.field),
490
- };
491
- if (isFullMarket(body, "aShares")) {
492
- // Full-market fund-flow: the server errors (430012/430013) instead of truncating when
493
- // a single request exceeds the row cap, so date-shard by day (~5.4k A-share rows/day,
494
- // under the lifted API cap) and merge — same mechanism as `--security all` kline.
495
- // Sharding needs an explicit range; without both dates it would fall back to one
496
- // doomed full-market request, so require the range up front with a clear message.
497
- if (!body.startDate || !body.endDate) {
498
- throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
560
+ 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) => {
561
+ // Realtime takes the same market keywords as day-kline but never shards (one snapshot
562
+ // per security), so it only needs the "alone, and a keyword this API knows" check.
563
+ checkMarketKeywords(options.security, REALTIME_MARKETS, "quote realtime");
564
+ return emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) }));
565
+ });
566
+ quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 872931.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) => {
567
+ // fund-flow needs this guard MORE than the kline commands, not less: mixing the keyword
568
+ // with codes doesn't even fail here. The server silently drops `aShares` and answers
569
+ // with just the explicit codes — one row, exit 0, no warning — so "whole market plus
570
+ // this one" quietly becomes "only this one". Validate before withClient so no login
571
+ // request is spent on a query that fails locally.
572
+ checkMarketKeywords(options.security, FUND_FLOW_MARKETS, "quote fund-flow");
573
+ options.security = canonicalizeMarketKeywords(options.security, FUND_FLOW_MARKETS);
574
+ return withClient(async (client) => {
575
+ const format = parseOutputFormat(options.format);
576
+ const body = {
577
+ securityList: maybeArray(options.security),
578
+ startDate: options.startDate,
579
+ endDate: options.endDate,
580
+ limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }),
581
+ fieldList: maybeArray(options.field),
582
+ };
583
+ if (isFullMarket(body, "aShares")) {
584
+ // Full-market fund-flow: the server errors (430012/430013) instead of truncating when
585
+ // a single request exceeds the row cap, so date-shard by day (~5.4k A-share rows/day,
586
+ // under the lifted API cap) and merge — same mechanism as `--security all` kline.
587
+ // Sharding needs an explicit range; without both dates it would fall back to one
588
+ // doomed full-market request, so require the range up front with a clear message.
589
+ if (!body.startDate || !body.endDate) {
590
+ throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
591
+ }
592
+ const data = await callKlineWithSharding(client, "quote.fund-flow", body, { shardDays: 1, fullMarketValue: "aShares" });
593
+ await printData(data, format, options.output);
594
+ return;
499
595
  }
500
- const data = await callKlineWithSharding(client, "quote.fund-flow", body, { shardDays: 1, fullMarketValue: "aShares" });
596
+ const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
597
+ const data = await client.call("quote.fund-flow", { ...body, limit });
598
+ flagIfLimitTruncated(data, limit, "fund-flow");
501
599
  await printData(data, format, options.output);
502
- return;
503
- }
504
- const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
505
- const data = await client.call("quote.fund-flow", { ...body, limit });
506
- flagIfLimitTruncated(data, limit, "fund-flow");
507
- await printData(data, format, options.output);
508
- }));
600
+ });
601
+ });
509
602
  program.addCommand(quote);
510
603
  const fundamental = new Command("fundamental").description("Fundamental APIs");
511
604
  const addFinancialReport = (name, endpointKey, periodHelp = "Period") => fundamental.command(name).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")).option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", periodHelp, collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call(endpointKey, { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) })));
@@ -643,11 +736,14 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
643
736
  }
644
737
  }));
645
738
  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)));
646
- 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), or market keyword: aShares / hkStocks; max 6000", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
739
+ 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
740
  // Guard against an empty --security: omitting it would send securityList:undefined,
648
741
  // which the backend may treat as all-market (3 credits/row × thousands of rows).
649
742
  if (!options.security.length)
650
- throw new ValidationError("--security is required: pass security code(s) or a market keyword (aShares / hkStocks)");
743
+ throw new ValidationError("--security is required: pass one or more security codes (A-share / HK), up to 6000 per call");
744
+ // The endpoint dropped whole-market batches on 2026-08-14 and now answers a market
745
+ // keyword with 120001 "invalid security code" — which reads as a typo in the code.
746
+ checkMarketKeywords(options.security, [], "ai stock-summary");
651
747
  return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
652
748
  });
653
749
  const reference = new Command("reference").description("Reference data APIs");
@@ -690,7 +786,7 @@ vault.command("record-list").option("--from <number>", "Starting offset", "0").o
690
786
  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" });
691
787
  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" }));
692
788
  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" });
693
- 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 -- NOT effective on this endpoint: both code sets, and even a garbage value, return the unfiltered total. Filter on industryList[] client-side", 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))));
789
+ 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))));
694
790
  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))));
695
791
  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", {})));
696
792
  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))));
@@ -784,7 +880,7 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
784
880
  });
785
881
  await printData(unwrapIndicatorData(raw), format, options.output);
786
882
  }));
787
- indicator.command("cross-section").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("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate — the server resolves that to the enclosing report period for report-period indicators, or pass --indicator-param 'code:reportDate=...' to be explicit", dateArg("--date")).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, unique & order-stable for batch code→value mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
883
+ indicator.command("cross-section").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("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate. Report-period indicators (is_*, financial statements) REJECT tradeDate and require --indicator-param 'code:reportDate=yyyy-MM-dd' instead — check parameterList in 'indicator search'", dateArg("--date")).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, unique & order-stable for batch code→value mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
788
884
  const format = parseOutputFormat(options.format);
789
885
  requireIndicatorScope(options.indicator, options.security);
790
886
  const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
@@ -809,7 +905,7 @@ indicator.command("time-series").option("--indicator <code>", "Indicator code, e
809
905
  flagDropped(rows, data, options.security, options.indicator);
810
906
  await printData(rows, format, options.output);
811
907
  }));
812
- indicator.command("screener").description("Screen securities by an expression over indicator values (条件选股)").option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", 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("--expression <expr>", "Filter over the bound variables, e.g. 'F1 >= 800 && (F2 >= 20 && F2 <= 30)'; also supports contains/notcontains on string indicators").requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as every indicator's tradeDate unless it already has one — omitting it leaves date-bearing indicators unfiltered and silently yields an empty screen", dateArg("--date")).option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
908
+ indicator.command("screener").description("Screen securities by an expression over indicator values (条件选股)").option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", 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("--expression <expr>", "Filter over the bound variables, e.g. 'F1 >= 800 && (F2 >= 20 && F2 <= 30)'; also supports contains/notcontains on string indicators").requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as every indicator's tradeDate unless it already has one — omitting it leaves date-bearing indicators unfiltered and silently yields an empty screen. Report-period indicators (is_*) reject tradeDate: give them --indicator-param 'F1:reportDate=yyyy-MM-dd'", dateArg("--date")).option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
813
909
  const format = parseOutputFormat(options.format);
814
910
  requireIndicatorScope(options.indicator, options.security);
815
911
  // Binding one indicator code to several variables (the same price on two
@@ -39,13 +39,15 @@ export function buildStockPoolStocksBody(options) {
39
39
  * indicator's `tradeDate`. A caller-supplied `reportDate` suppresses that
40
40
  * injection for its indicator, so the two date fields are never sent together.
41
41
  *
42
- * This used to be load-bearing in a stronger sense: a report-period indicator
43
- * answered a `tradeDate` with an EMPTY result rather than an error (probed
44
- * 2026-08-01 on `is_op_rev_mom`). Re-probed 2026-08-08 — the server now resolves
45
- * a `tradeDate` to the enclosing report period, and `is_op_rev_mom` returns the
46
- * same 33.4903 either way. Kept regardless: sending the caller's own date field
47
- * rather than a second one alongside it is the honest request, and it survives a
48
- * rollback. Injecting into an indicator that takes no date at all is harmless —
42
+ * This is load-bearing, and the server has moved twice on it. A report-period
43
+ * indicator answered a `tradeDate` with an EMPTY result (probed 2026-08-01 on
44
+ * `is_op_rev_mom`), then resolved it to the enclosing report period (2026-08-08,
45
+ * same 33.4903 either way), and since 2026-08-14 REJECTS it outright:
46
+ * `100003 指标 is_op_rev_mom 不支持参数 tradeDate; 缺少必填参数 reportDate`
47
+ * (re-probed 2026-08-15; `reportDate` still returns 33.4903). So a caller-supplied
48
+ * `reportDate` is now the only way to reach those indicators, and suppressing the
49
+ * `tradeDate` injection is what makes it work rather than merely being tidier.
50
+ * Injecting into an indicator that takes no date at all is harmless —
49
51
  * `pty_op_scope` still answers normally with a stray `tradeDate` attached.
50
52
  *
51
53
  * `sDate` is deliberately NOT here. It is an interval START, not a substitute:
@@ -66,7 +66,8 @@ const ERROR_HINTS = {
66
66
  "100003": "msg 已指明字段名或取值范围时直接按 msg 改;msg 只说「参数值非法」时多为枚举参数拼写错误(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
67
67
  "100004": "检查 --size / --from 是否为非负数且未超单页上限。",
68
68
  "100005": "对照命令 --help 列出的合法取值检查。",
69
- "100006": "缩短日期范围或调小 --size / --limit。",
69
+ // 2026-08-14 起 list 类端点把单页上限(50)也归到此码,不再只是「一次要太多行」。
70
+ "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守)。",
70
71
  // 按参数名判断,不要按命令组:AI 下 management-discuss 的 --report-date 是 date,
71
72
  // 而同属 AI 的 knowledge-batch 收时间戳或 datetime——旧文案笼统写"AI 用 datetime"会把
72
73
  // --report-date 的用户越导越错。
@@ -77,8 +78,10 @@ const ERROR_HINTS = {
77
78
  "110003": "查询时间超出本账号的数据权限范围——把日期移进范围内(整个区间都早于下界时缩短窗口无用),或联系客户经理开通更长历史。",
78
79
  "120001": "用 `gangtise reference securities-search` 确认代码与后缀(如 600519.SH / 00700.HK)。",
79
80
  "130001": "未找到数据——先核对查询条件;EDE 指标端点此码也可能是未开通该指标权限,仍失败联系客户经理。",
80
- "130002": "确认下载 ID 有效且本账号可见;下载类还需检查 --file-type 取值是否合法(非法 file-type 也归此码)。",
81
- "130003": "该条记录可能未附带文件。",
81
+ // 2026-08-14 起下载类把「file-type 非法」拆到 130005、「资源未生成」拆到 130003,
82
+ // 但历史上两者都归过此码,所以提示保留对 --file-type 的指引。
83
+ "130002": "确认下载 ID 有效且本账号可见;下载类还需检查 --file-type 取值是否合法。",
84
+ "130003": "资源未生成或该条记录未附带文件——换一条有附件的记录(列表里 hasAttachment/hasFile 为 true 的),或稍后再试。",
82
85
  // 下载类命令各有各的 ID 参数(--report-id / --announcement-id / --chunk-id /
83
86
  // --summary-id / --conference-id / --record-id / --file-id / --article-id /
84
87
  // --independent-opinion-id);--data-id 是异步 *-check 用的,不产生此码。
@@ -125,6 +128,31 @@ const ERROR_HINTS = {
125
128
  "999997": "联系客户经理开通。",
126
129
  "10011401": "联系客户经理开通白名单。",
127
130
  };
131
+ /** Hints keyed on the server's message rather than its code. Used where one code
132
+ * covers many causes and the message identifies which — `100003` in particular is
133
+ * the catch-all for every EDE input error, so its per-code hint can only be generic.
134
+ *
135
+ * Kept to cases where the server names the problem but not the CLI flag that fixes
136
+ * it: the caller is told "缺少必填参数 reportDate" and still has to work out that
137
+ * such indicators need `--indicator-param`, not `--date`.
138
+ *
139
+ * ⚠️ Do NOT restate this as a rule about code prefixes or as a clean two-way split.
140
+ * Survey of 170 indicators (2026-08-15) says otherwise: 117 take `reportDate` only,
141
+ * 51 take `tradeDate` only, `div_cash_yld` takes BOTH (both required), and
142
+ * `div_cash_yr` / `div_cash_paid_ratio` take NEITHER (they want `fiscalYear`).
143
+ * Prefixes cut across it too — 7 `finc_*` and 3 `div_*` want `reportDate`, while 8
144
+ * `is_*` and 4 `cf_*` want `tradeDate`. The hint therefore points at
145
+ * `indicator search` rather than asserting which indicators are affected.
146
+ *
147
+ * Both codes are listed because the server sends this same sentence under either:
148
+ * `is_op_rev` answers 100003, `div_cash_yld` answers 100001 (probed 2026-08-15). */
149
+ const MESSAGE_HINTS = [
150
+ {
151
+ codes: ["100001", "100003"],
152
+ match: /不支持参数\s*tradeDate|缺少必填参数[::]?\s*reportDate/,
153
+ hint: "这个指标要的是 reportDate(报告期),不是 --date 下发的 tradeDate。补 --indicator-param \"<指标code>:reportDate=YYYY-MM-DD\"(screener 用 \"F1:reportDate=...\"),--date 仍要保留。⚠️ 少数指标两个日期都要(如 div_cash_yld),补完 reportDate 若再报缺 tradeDate,就把 tradeDate 也显式传上;另有指标要的是 fiscalYear。**以 `indicator search` 返回的 parameterList 为准,别按 code 前缀推断**。",
154
+ },
155
+ ];
128
156
  export class ApiError extends CliError {
129
157
  code;
130
158
  statusCode;
@@ -145,7 +173,13 @@ export class ApiError extends CliError {
145
173
  this.statusCode = statusCode;
146
174
  this.details = details;
147
175
  this.retryAfterMs = retryAfterMs;
148
- this.hint = hintOverride ?? (code ? ERROR_HINTS[code] : undefined);
176
+ // Precedence: explicit override > message-specific > per-code. The message rule
177
+ // sits in the middle because it identifies a narrower cause than the code alone,
178
+ // but a call site that already knows the context still knows better.
179
+ const byMessage = code
180
+ ? MESSAGE_HINTS.find((rule) => rule.codes.includes(code) && rule.match.test(message))?.hint
181
+ : undefined;
182
+ this.hint = hintOverride ?? byMessage ?? (code ? ERROR_HINTS[code] : undefined);
149
183
  }
150
184
  /** Server-side correlation id from the 2026-07-17 envelope
151
185
  * (`{code, errorType, msg, status, data, traceId}`). Read off `details` rather
@@ -1,8 +1,8 @@
1
1
  import { isVerbose, PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
2
2
  const DAY_MS = 86_400_000;
3
- /** API-side row cap (per docs). Used to lift the default 6000-row cap on
4
- * `--security all` queries so a 2-day A-share shard (~11K rows) isn't
5
- * silently truncated. Single-security queries are untouched. */
3
+ /** API-side row cap (per docs). Used to lift the default 6000-row cap on whole-market
4
+ * queries so a 2-day A-share shard (~11K rows) isn't silently truncated. Single-security
5
+ * queries are untouched. */
6
6
  const ALL_MARKET_LIMIT = 10_000;
7
7
  function parseDate(value) {
8
8
  // Accept yyyy-MM-dd; reject anything else so we can fall back to a single request.
@@ -22,11 +22,6 @@ export function isFullMarket(body, fullMarketValue) {
22
22
  return false;
23
23
  return list[0] === fullMarketValue;
24
24
  }
25
- /** Kline uses `all` as its whole-market keyword; fund-flow uses `aShares`. Thin wrapper
26
- * so kline call sites read naturally. */
27
- export function isAllMarket(body) {
28
- return isFullMarket(body, "all");
29
- }
30
25
  /** Sat/Sun in UTC — shard dates are formatted from UTC midnight (see parseDate). */
31
26
  function isWeekendUtc(d) {
32
27
  const day = d.getUTCDay();
@@ -40,9 +35,14 @@ function buildShards(start, end, shardDays) {
40
35
  const shardEnd = Math.min(cursor + (shardDays - 1) * DAY_MS, endTime);
41
36
  // Per-day sharding (shardDays===1): a lone weekend day always returns empty (A/HK/US
42
37
  // markets closed) — skip it to save ~28% of requests and daily quota. This covers
43
- // every 1-day-sharded full-market query: fund-flow AND day-kline / day-kline-us
44
- // (both shardDays 1 in cli.ts). Multi-day shards (day-kline-hk=2, index=30) always
45
- // straddle weekdays, so they're never dropped.
38
+ // every 1-day-sharded full-market query: fund-flow AND day-kline (aShares/usStocks)
39
+ // / day-kline-us.
40
+ //
41
+ // Multi-day shards (day-kline hkStocks=2, index-day-kline=15) are NOT filtered. Note
42
+ // this is a deliberate simplification, not a claim that they always contain a weekday:
43
+ // a 2-day shard starting on a Saturday is Sat+Sun and returns nothing. That costs one
44
+ // wasted request at a range boundary and never drops a trading day, whereas filtering
45
+ // multi-day windows correctly would mean walking each window's days.
46
46
  if (!(shardDays === 1 && isWeekendUtc(new Date(cursor)))) {
47
47
  shards.push({
48
48
  startDate: formatDate(new Date(cursor)),