gangtise-openapi-cli 0.32.0 → 0.34.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,16 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
- - **v0.32.0 — 2026-08-08**:新增帕米尔专家纪要列表与下载;跟进 2026-08-07 服务端修复——EDE 缺数据不再整列/整行消失(退出码 3 现在专指「代码没被识别」),条件选股重复指标的 `unreliable` 告警随之移除;`--search-type`/`--rank-type`/`--file-type` 等枚举改为本地拦截(非法值曾被服务端静默忽略并返回全量)。
9
+ - **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`,错误码提示按新行为更新。
10
+ - **v0.33.0 — 2026-08-09**:四处行为变更,都是把「静默给出看着正常的错结果」改成显式失败——分页端点返回异形首包(含 `data: null`)改退出码 3;`total` 被服务端封顶时探测并标 `totalCapped` + 退出 3(三个 opinion 端点的 `total` 恒为 10000 而实际远不止,全量导出此前会被静默截断);空结果不再在 stdout 留空行、`null` 不再被渲染成一条记录。另补多处帮助文案与 EDE 占位值(个别指标填 `0` 而非 `null`)的说明。
11
+ - **v0.32.0 — 2026-08-08**:新增帕米尔专家纪要列表与下载;跟进 2026-08-07 服务端更新——EDE 缺数据不再整列/整行消失(退出码 3 现在专指「代码没被识别」),条件选股重复指标的 `unreliable` 告警随之移除;`--search-type`/`--rank-type`/`--file-type` 等枚举改为本地拦截(此前传非法值不会报错,会拿到未过滤的全量)。
10
12
  - **v0.31.0 — 2026-08-03**:修复 v0.30.1 的矩阵维度校验误杀「单指标 × 板块 ID」时序查询的回归;EDE 结果不完整或不可信时改以退出码 3 标记(`partial` / `unreliable`)。
11
13
  - **v0.30.1 — 2026-08-02**:修复 `sDate` 吞掉查询日期导致的区间指标静默错数,让条件选股的文本筛选真正可用,并在服务端整行/整列丢数据时给出警告。
12
14
  - **v0.30.0 — 2026-08-02**:适配 EDE 接口重构(`universe` 取代 `securityCodeList`、截面矩阵转置、指标元数据结构化),新增 `indicator screener` 条件选股,并修正复权参数名。
13
- - **v0.29.0 — 2026-07-25**:新增财报日历与 PDF 解析工具,群消息补 `quoteMsg`,并加强大整数 ID 与高积分调用防护。
14
15
 
15
16
  ### 历史里程碑
16
17
 
18
+ - **v0.29.0**:新增财报日历与 PDF 解析工具,群消息补 `quoteMsg`,并加强大整数 ID 与高积分调用防护。
17
19
  - **v0.26.0–v0.27.0**:建立高积分端点 `no-replay`、原子下载与容错分页机制,并补齐 Skill 分发和发布质量门禁。
18
20
  - **v0.22.0–v0.23.0**:统一“省略 `--size` 即拉全量”的分页语义,引入机器可识别的部分结果、Token 自愈,并完成 API 域名迁移与资金流向、机构搜索支持。
19
21
  - **v0.19.0–v0.20.0**:上线 EDE 证券指标接口,扩展美股公告与财务报表,同时加强凭证脱敏、CSV 正确性和分页容错。
@@ -169,9 +171,10 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
169
171
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
170
172
  | | `sector-search` | 板块 ID 搜索(返回层级路径) |
171
173
  | | `sector-constituents` | 板块成分股查询 |
172
- | **Quote** | `day-kline` / `day-kline-hk` / `day-kline-us` | A股/港股/美股历史日K线 |
173
- | | `index-day-kline` | 沪深京指数日K线 |
174
- | | `minute-kline` | A股分钟K线 |
174
+ | **Quote** | `day-kline` | 历史日K线——A股/港股/美股个股 + 交易所/概念/行业指数,可混查 |
175
+ | | `day-kline-hk` / `day-kline-us` | ⚠️ 已下线,能力并入 `day-kline`(接口仍可调,但不校验证券代码) |
176
+ | | `index-day-kline` | ⚠️ 已下线,能力并入 `day-kline`;但仍是取「全部沪深京指数」(`--security all`)和拿指数名称(`securityName`)的唯一方式 |
177
+ | | `minute-kline` | 分钟K线——沪深A股 + 各类指数(一次一只) |
175
178
  | | `realtime` | 个股实时行情快照(A股/港股/美股) |
176
179
  | | `fund-flow` | A股个股日资金流向(沪深京;小/中/大/特大单 + 主力净流入) |
177
180
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
@@ -185,7 +188,7 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
185
188
  | **AI** | `knowledge-batch` | 知识库批量检索 |
186
189
  | | `knowledge-resource-download` | 知识资源下载 |
187
190
  | | `security-clue` | 个股线索 |
188
- | | `stock-summary` | 个股看点(精炼投研总结,按代码或全市场;仅 A 股/港股) |
191
+ | | `stock-summary` | 个股看点(精炼投研总结,按代码批量、单次最多 6000 个;仅 A 股/港股,不支持全市场) |
189
192
  | | `one-pager` | 一页通 |
190
193
  | | `investment-logic` | 投资逻辑 |
191
194
  | | `peer-comparison` | 同业对比 |
@@ -233,8 +236,8 @@ cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
233
236
 
234
237
  ```bash
235
238
  gangtise reference constant-category # 有哪些常量分类、各用于哪些参数
236
- gangtise reference constant-list --category citicIndustry # 中信行业(--industry 通用)
237
- gangtise reference constant-list --category gangtiseIndustry # Gangtise 行业 + 方向(--research-area 用)
239
+ gangtise reference constant-list --category citicIndustry # 中信行业(--industry / --research-area 的行业维度都用它)
240
+ gangtise reference constant-list --category gangtiseIndustry # 研究方向 6 条(宏观/策略/固收/金工/海外/其他),不含行业
238
241
  gangtise reference constant-list --category swIndustry # 申万行业
239
242
  gangtise reference constant-list --category regionCategory # 外资研报区域
240
243
  gangtise reference constant-list --category aShareAnnouncementCategory # A股公告分类(树形)
@@ -258,9 +261,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
258
261
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
259
262
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
260
263
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
261
- - **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档端点例外**(one-pager 等生成/提交类 + `tool file-parse` 提交 + 50/篇 的 summary / foreign-report / my-conference 下载 + 单价未公布但保守同档的 pamirs-summary 下载,共 18 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。**`indicator`(EDE)端点对 `999999` 不重试**——重放一次已计费的查询没有意义(该码 2026-08-01 前还兼表「查询无数据」,现在无数据是保留行列的 `null` 单元格,空表另表示整轴 code 未识别)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
264
+ - **自动重试**: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 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
262
265
  - **Token 自愈**:调用返回 `0000001008` / `999002` 时自动强制刷新 Token 并重试一次。
263
- - **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
266
+ - **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 截断。
264
267
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
265
268
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
266
269
 
@@ -299,7 +302,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
299
302
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
300
303
  - 安全上限:自动翻页最多 1000 页,防止异常循环
301
304
  - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
302
- - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。**v0.32.0 起这个信号的含义变窄了**——2026-08-07 服务端改为给缺数据补 `null`(行列都保留),所以整列/整行消失现在只发生在**服务端解析不了那个 code** 时(指标码拼错,或证券后缀错,如美股写成 `AAPL.US` 而非 `AAPL.O`)。真实的无数据/无覆盖是 `null` 单元格 + 退出码 0。⚠️ **这个检测需要同批里有一个能解析的对照物**:拼错的 code 与正确的 code 混在一批才会标 partial;**整个轴都写错时(如只查一个拼错的指标)响应是空表、退出码 0**,只有 stderr 提示——空表拿到手必须先核对代码拼写与后缀。同版本移除了条件选股重复指标的 `unreliable` / `duplicatedIndicators` 标记(服务端已修复,继续告警会是误报)。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。⚠️ 这一档以「服务端返回了命中行」为前提;**零命中时一律退出码 0**(没有行需要被质疑),所以空集要先核对指标码拼写,不能直接当成「无标的符合条件」。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整/`1` 硬失败
305
+ - **`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` 硬失败
306
+ - **分页端点返回 `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 个字节**。
307
+ - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:`insight opinion` / `foreign-opinion` / `independent-opinion` 三个端点的 `total` 恒为 `10000`,而实际记录远不止(把 `from` 加到远超该值仍能取到真实记录)。省略 `--size` 的全量拉取本来会**正好取满 10000 条就停、且不报任何异常**——导出的文件是截断的却看不出来。现在全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
303
308
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
304
309
 
305
310
  ## 智能文件命名
@@ -433,16 +438,15 @@ gangtise reference sector-constituents --sector-id 1000001005 --format json
433
438
  gangtise quote day-kline --security 600519.SH --start-date 2026-03-01 --end-date 2026-03-31
434
439
  # 查最近/最新 K 线建议显式传 --start-date/--end-date;只传 --limit 会截取查询窗口开头,不等于最近N条
435
440
  gangtise quote day-kline --format json
436
- # 全市场查询(--security all)
437
- gangtise quote day-kline --security all --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
438
- # 港股日K线
439
- gangtise quote day-kline-hk --security 00700.HK --start-date 2026-03-01 --end-date 2026-03-31
440
- # 港股全市场
441
- gangtise quote day-kline-hk --security all --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
442
- # 美股日K线(NASDAQ/NYSE/AMEX,历史)
443
- 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
444
- # 美股全市场(自动分片)
445
- gangtise quote day-kline-us --security all --start-date 2026-04-01 --end-date 2026-04-02 --field securityCode --field close --format json
441
+ # 全市场查询:关键字是 aShares / hkStocks / usStocks,必须单独传(旧的 --security all 已不再支持)
442
+ gangtise quote day-kline --security aShares --start-date 2026-04-01 --end-date 2026-04-01 --limit 100 --format json
443
+ # 港股 / 美股 / 指数都走同一个 day-kline,可混着传
444
+ gangtise quote day-kline --security 00700.HK --security AAPL.O --start-date 2026-03-01 --end-date 2026-03-31
445
+ gangtise quote day-kline --security 000001.SH --security 880134.GT --security 821031.SWI --start-date 2026-03-01 --end-date 2026-03-31
446
+ # 港股全市场(自动按 2 天/片分片)
447
+ gangtise quote day-kline --security hkStocks --start-date 2026-04-01 --end-date 2026-04-10 --format json
448
+ # 美股全市场(自动按 1 天/片分片)
449
+ gangtise quote day-kline --security usStocks --start-date 2026-04-01 --end-date 2026-04-02 --field securityCode --field close --format json
446
450
  # 沪深京指数日K线
447
451
  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
448
452
  # A股分钟K线
@@ -604,7 +608,7 @@ gangtise indicator screener --indicator F1:pty_op_scope \
604
608
  --date 2026-07-31 --format table
605
609
 
606
610
  # 复权 / 指标专属参数用 --indicator-param "code:key=value"
607
- # ⚠️ 参数名必须以 search 的 parameterList 为准:传错名服务端静默忽略、退回默认值,不报错
611
+ # ⚠️ 参数名必须以 search 的 parameterList 为准;写错名会报 100003 并指出是哪个指标的哪个参数
608
612
  gangtise indicator cross-section --indicator qte_close --security 600519.SH \
609
613
  --date 2024-01-02 --indicator-param "qte_close:adjustType=3" # 1不复权/2前复权/3后复权/4定点
610
614
  # 不复权 1685.01 → 前复权 1531.225 → 后复权 13609.6168(前复权在最新交易日等于不复权,验证要用历史日)
@@ -713,7 +717,7 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
713
717
  | `999010` | 接口地址不存在(`raw call` 的 key 可能已下线,用 `raw list` 核对) |
714
718
  | `999012` / `999013` / `999014` | 账号禁用 / 已过期 / 租户失效 |
715
719
  | `999016` | 调用方 IP 不在允许范围 |
716
- | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点的「无数据」已不再用此码——有效 code 无数据返回 `null` 单元格,此码基本只剩真故障) |
720
+ | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点的「无数据」已不再用此码——有效 code 无数据返回占位单元格(多数 `null`、个别指标 `0`),此码基本只剩真故障) |
717
721
  | `140002` | 终态失败:AI 异步生成失败,或 `indicator` 的参数/表达式错误(枚举越界、语法错)——改参数重提,不重试 |
718
722
  | `100003` | 参数值非法——**最宽的兜底码**;msg 通常已指明字段(如「limit 最小为 1,最大为 10000」),先读 msg |
719
723
  | `100001` | 缺必填参数(msg 带字段名,如「缺少必填参数: reportId」) |
@@ -722,14 +726,14 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
722
726
  | `120001` | 证券代码无效(用 `reference securities-search` 确认代码与后缀) |
723
727
  | `130001` | 数据未找到或无指标权限——**取代旧 `410004`** |
724
728
  | `130002` | 资源不存在——**下载类的兜底码**,`--report-id` 不存在 / 非数字 / `--file-type` 非法都归这里(取代旧 `430004`) |
725
- | `410110` / `410111` | 异步任务生成中(继续轮询)/ 生成失败(终态)——**实测服务端仍发这两个旧码**;新码 `140001`/`140002`,CLI 两代都认 |
729
+ | `410110` / `410111` | 异步任务生成中(继续轮询)/ 生成失败(终态)——异步端点当前返回的是这两个码;新码为 `140001`/`140002`,CLI 两代都认 |
726
730
  | `240001` | 财报期未披露或超出查询期(`earnings-review` 提交阶段即报,不扣积分) |
727
731
  | `250001` | 不支持该数据源(`knowledge-resource-download` 需正确的 `resourceType + sourceId` 组合)——**取代旧 `433007`** |
728
732
  | `900002` | 请求方法不正确(服务端 msg 为「请求类型有误」,HTTP 405) |
729
733
 
730
734
  > **关于这次错误码重排**:服务端 2026-07-17 重排了 41 个公开码(三层:`999xxx` 服务统一层 / `1xxxxx` 业务通用层 / `2xxxxx` 接口专有层),信封新增 `errorType` 和 `traceId`。2026-07-20 逐码实测发现**迁移是按「错误处理层」而非按业务模块进行的**:同一个接口内,参数校验层与路由层已发新码,方法路由层、token 过滤器、以及异步生成状态仍发旧码。新码信封 `code` 是 JSON 数字且带 `errorType`,旧码是字符串且没有——但这判断的是单条错误路径,不是整个接口;CLI 对两代都能识别。报错行会带 `[trace <id>]`,**报障时请带上它**。
731
735
  >
732
- > 其余码(`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` 失败。
736
+ > 其余码(`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` 失败。
733
737
 
734
738
  ---
735
739
 
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";
@@ -174,13 +174,13 @@ const independentOpinion = new Command("independent-opinion");
174
174
  const officialAccount = new Command("official-account");
175
175
  const qa = new Command("qa");
176
176
  const reportImage = new Command("report-image");
177
- addTimeFilters(opinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
177
+ addTimeFilters(opinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).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("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
178
178
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
179
179
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
180
180
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker), industryList: maybeArray(options.industry), conceptList: maybeArray(options.concept),
181
181
  llmTagList: maybeArray(options.llmTag), sourceList: maybeArray(options.source),
182
182
  })));
183
- addTimeFilters(summary.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--source <number>", "Source type", collectNumberList, []).option("--research-area <id>", "Research area", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.summary.list", {
183
+ addTimeFilters(summary.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--source <number>", "Source type", collectNumberList, []).option("--research-area <id>", "Research area ID; this endpoint accepts all three code sets: citicIndustry (1008001xx), swIndustry (104xx0000), gangtiseIndustry direction (122000xxx)", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.summary.list", {
184
184
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
185
185
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, sourceList: options.source.length ? options.source : undefined,
186
186
  researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution),
@@ -200,7 +200,7 @@ addDownloadCommand(summary, { endpointKey: "insight.summary.download", idOption:
200
200
  // that put whitelists on securities-search / institution-search / official-account.
201
201
  const PAMIRS_CATEGORIES = ["companyAnalysis", "industryAnalysis"];
202
202
  const PAMIRS_MARKETS = ["aShares", "hkStocks", "usChinaConcept", "usStocks"];
203
- addTimeFilters(pamirsSummary.command("list").description("List Pamirs expert summaries (requires the expert-summary database)").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID; unlike most insight lists this accepts BOTH citicIndustry (1008001xx) and swIndustry (104xx0000) codes", collectList, []).option("--security <code>", "Security code, e.g. 000001.SZ", collectList, []).option("--category <name>", `Category: ${PAMIRS_CATEGORIES.join(" / ")}`, collectList, []).option("--market <name>", `Market: ${PAMIRS_MARKETS.join(" / ")}`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.pamirs-summary.list", {
203
+ addTimeFilters(pamirsSummary.command("list").description("List Pamirs expert summaries (requires the expert-summary database)").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID: accepts BOTH industry code sets -- citicIndustry (1008001xx) and swIndustry (104xx0000) -- but NOT gangtiseIndustry direction codes (122000xxx), which return 0 on this endpoint only", collectList, []).option("--security <code>", "Security code, e.g. 000001.SZ", collectList, []).option("--category <name>", `Category: ${PAMIRS_CATEGORIES.join(" / ")}`, collectList, []).option("--market <name>", `Market: ${PAMIRS_MARKETS.join(" / ")}`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.pamirs-summary.list", {
204
204
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
205
205
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword,
206
206
  researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security),
@@ -210,7 +210,7 @@ addDownloadCommand(pamirsSummary, { endpointKey: "insight.pamirs-summary.downloa
210
210
  const addScheduleList = (command, endpointKey, fields) => {
211
211
  const list = command.command("list");
212
212
  if (fields.researchArea)
213
- list.option("--research-area <id>", "Research area ID (constant-list category gangtiseIndustry: 1008001xx industries + 122000xxx macro/strategy/fixed-income/quant/overseas directions)", collectList, []);
213
+ list.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, []);
214
214
  if (fields.institution)
215
215
  list.option("--institution <id>", "Lead institution ID", collectList, []);
216
216
  if (fields.security)
@@ -380,7 +380,7 @@ addTimeFilters(announcementUs.command("list").addOption(new Option("--search-typ
380
380
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
381
381
  }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
382
382
  addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
383
- addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
383
+ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code -- UNRELIABLE on this endpoint: cn/us/jp yield a null payload (no error); only gl returns rows. Filter on the region field client-side", collectList, []).option("--industry <id>", "Industry ID -- NOT effective on this endpoint: any value yields a null payload (not an empty list), with no error. Omit it and filter on industryList[] client-side", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
384
384
  from: parseFrom(options.from), size: parseSize(options.size),
385
385
  startTime: options.startTime, endTime: options.endTime,
386
386
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -389,7 +389,7 @@ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type
389
389
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker),
390
390
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
391
391
  })));
392
- addTimeFilters(independentOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. GSK.N)", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.independent-opinion.list", {
392
+ addTimeFilters(independentOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. GSK.N)", collectList, []).option("--industry <id>", "Industry ID -- NOT effective on this endpoint: any value yields a null payload (not an empty list), with no error. Omit it and filter on industryList[] client-side", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.independent-opinion.list", {
393
393
  from: parseFrom(options.from), size: parseSize(options.size),
394
394
  startTime: options.startTime, endTime: options.endTime,
395
395
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -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");
@@ -688,9 +784,9 @@ vault.command("drive-list").option("--from <number>", "Starting offset", "0").op
688
784
  addDownloadCommand(vault, { endpointKey: "vault.drive.download", name: "drive-download", idOption: "--file-id", idField: "fileId", fallbackPrefix: "file", titleListEndpoint: "vault.drive.list" });
689
785
  vault.command("record-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("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", collectList, []).option("--space-type <number>", "Space type: 1=my records / 2=tenant records", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.record.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, categoryList: maybeArray(options.category), spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), { endpointKey: "vault.record.list", idField: "recordId" }));
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
- 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", 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" }));
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", 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