gangtise-openapi-cli 0.35.0 → 0.37.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,11 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
- - **v0.35.0 — 2026-08-16**:新增 `--indicator-param "<code>:"`(冒号后留空)声明「这个指标不要查询日期」,用于 `parameterList` 里没有日期参数的指标——`pty_*`(经营范围/注册地/法定代表人…)、`scr_*`(上市市场/上市板块/ISIN…)两族,以及 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg`。这类指标此前在 `indicator cross-section` 上取不到数(`--date` 必填且会注入 `tradeDate`,而它们不收,整条请求被拒);该写法可与真实参数共存(`"code:" + "code:fiscalYear=2025"`)。**EDE 日期参数报错提示重写**:拆成五种报文形态分别给建议,服务端同时点名「不该有的键」和「缺的键」时直说换哪个,只说了一半时不再瞎猜,多指标批量报错时不再用单数口吻指向其中一个指标。另:`--rank-type` 的说明按实测更正(差别大小取决于关键词,`--search-type` 不影响 `--rank-type 1` 取回哪些条目);`ai hot-topic` 全量拉取不再多花一次按次计费的调用。
9
+ - **v0.37.0 — 2026-08-29**:🔴 **下载的「智能文件命名」改为默认只读缓存**。省略 `--output` 时仍优先用 `title-cache` 里的真实标题——先 `list` 再 `download` 的常规用法**不受影响,也不产生额外调用**;但**缓存未命中时不再自动回查 list 接口**,改为退回服务端返回的文件名或 `<type>-<id>.<ext>`。回查一次要拉 200 条记录(4 次请求),而这 12 个下载命令里有 9 个的 list 按 0.1 积分/条计费,约 20 积分——这笔开销只用于取一个更易读的文件名,所以改成显式的 `--resolve-title`。加了该参数时,取回的 200 条标题会一并写入缓存,同一批后续下载不再重复回查。⚠️ **依赖旧行为拿中文文件名的脚本**:升级后会得到 ID 文件名,补 `--resolve-title`,或按推荐用法先跑一次 `list`。**另有三项修正**:① 下载已成功、仅标题回查阶段遇到异常响应时,退出码会变成 3(脚本按 `!= 0` 判失败会误判为下载失败),现已隔离——下载完整就是 0;② `indicator cross-section` / `time-series` 的 `--indicator-param` 若写了 `--indicator` 里没有的指标编码(多为拼写错误),此前该参数不会作用到你要查的指标上且没有任何提示,现改为发请求前直接报错并指出是哪个编码(`screener` 一直是这个行为);③ 下载请求的超时改为与其他请求同一套解析逻辑,行为对齐。
10
+ - **v0.36.0 — 2026-08-18**:**日期写法放宽**——`YYYY-MM-DD`、`YYYY/MM/DD`、`YYYYMMDD` 三种「年在前」写法都收,统一归一成 `YYYY-MM-DD` 发出(datetime 只归一日期部分,Unix 时间戳原样透传);「年在后」写法(`01-07-2026`)仍在本地拒绝——接口会按美式「月在前」解析它,欧洲习惯写法会静默拿到差半年的数据,详见「关于日期格式」。**`indicator screener` 支持无日期指标**:`--indicator-param "F1:"`(冒号后留空)声明该指标不要查询日期,`pty_*` / `scr_*` 静态属性两族与 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg` 现在可以直接用于条件选股(此前只能在 `cross-section` 取回后本地筛),写法与截面一致、可与真实参数共存。**全量拉取的 `total` 封顶探测恢复覆盖 `ai hot-topic`**(v0.35.0 曾跳过)。另:EDE 报错提示同步更新;补充计费说明(`ai hot-topic` 50/篇 的「篇」= 一整份报告;按篇/按条计费的接口查不到内容不扣分)。
11
+ - **v0.35.0 — 2026-08-16**:新增 `--indicator-param "<code>:"`(冒号后留空)声明「这个指标不要查询日期」,用于 `parameterList` 里没有日期参数的指标——`pty_*`(经营范围/注册地/法定代表人…)、`scr_*`(上市市场/上市板块/ISIN…)两族,以及 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg`。这类指标此前在 `indicator cross-section` 上取不到数(`--date` 必填且会注入 `tradeDate`,而它们不收,整条请求被拒);该写法可与真实参数共存(`"code:" + "code:fiscalYear=2025"`)。**EDE 日期参数报错提示重写**:拆成五种报文形态分别给建议,服务端同时点名「不该有的键」和「缺的键」时直说换哪个,只说了一半时不再瞎猜,多指标批量报错时不再用单数口吻指向其中一个指标。另:`--rank-type` 的说明按实测更正(差别大小取决于关键词,`--search-type` 不影响 `--rank-type 1` 取回哪些条目);`ai hot-topic` 全量拉取跳过 `total` 封顶探测(**已于 v0.36.0 撤回**——该探测不额外计费)。
10
12
  - **v0.34.1 — 2026-08-15**:EDE 报告期类指标传错日期参数时,报错里直接给出该改的 CLI 写法(此前只有服务端那句「缺少必填参数 reportDate」,要自己推断该用 `--indicator-param`);该提示对 `100001` / `100003` 两个错误码都生效,并注明少数指标两个日期都要、另有指标要 `fiscalYear`,一律以 `indicator search` 的 `parameterList` 为准。另修复下载文件名缓存在并发写入下可能丢条目的问题(单进程使用不受影响)。
11
13
  - **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`,错误码提示按新行为更新。
12
- - **v0.33.0 — 2026-08-09**:四处行为变更,都是把「静默给出看着正常的错结果」改成显式失败——分页端点返回异形首包(含 `data: null`)改退出码 3;`total` 被服务端封顶时探测并标 `totalCapped` + 退出 3(三个 opinion 端点的 `total` 恒为 10000 而实际远不止,全量导出此前会被静默截断);空结果不再在 stdout 留空行、`null` 不再被渲染成一条记录。另补多处帮助文案与 EDE 占位值(个别指标填 `0` 而非 `null`)的说明。
13
- - **v0.32.0 — 2026-08-08**:新增帕米尔专家纪要列表与下载;跟进 2026-08-07 服务端更新——EDE 缺数据不再整列/整行消失(退出码 3 现在专指「代码没被识别」),条件选股重复指标的 `unreliable` 告警随之移除;`--search-type`/`--rank-type`/`--file-type` 等枚举改为本地拦截(此前传非法值不会报错,会拿到未过滤的全量)。
14
- - **v0.31.0 — 2026-08-03**:修复 v0.30.1 的矩阵维度校验误杀「单指标 × 板块 ID」时序查询的回归;EDE 结果不完整或不可信时改以退出码 3 标记(`partial` / `unreliable`)。
15
- - **v0.30.1 — 2026-08-02**:修复 `sDate` 吞掉查询日期导致的区间指标静默错数,让条件选股的文本筛选真正可用,并在服务端整行/整列丢数据时给出警告。
16
- - **v0.30.0 — 2026-08-02**:适配 EDE 接口重构(`universe` 取代 `securityCodeList`、截面矩阵转置、指标元数据结构化),新增 `indicator screener` 条件选股,并修正复权参数名。
17
14
 
18
15
  ### 历史里程碑
19
16
 
@@ -263,7 +260,29 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
263
260
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
264
261
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
265
262
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
266
- - **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档端点例外**(one-pager 等生成/提交类 + `tool file-parse` 提交 + 50/篇 的 summary / foreign-report / my-conference 下载 + 单价未公布但保守同档的 pamirs-summary 下载,共 18 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。**`indicator`(EDE)端点对 `999999` 不重试**——重放一次已计费的查询没有意义(该码 2026-08-01 前还兼表「查询无数据」,现在无数据是保留行列的占位单元格(多数指标 `null`、个别如 `is_dnrpnp` 是 `0`),空表另表示整轴 code 未识别)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
263
+ - **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档端点例外**(one-pager 等生成/提交类 + `tool file-parse` 提交 + 50/篇 的 summary / foreign-report / my-conference 下载 + 单价未公布但保守同档的 pamirs-summary 下载,共 18 个):5xx/超时不重放——**重放会重复扣分**:服务端可能已经执行并计费,重发按次计费的再扣一次,重发按篇/按条计费的会把已交付的行再计一次;仅连接失败、429 与 token 自愈重试。**`indicator`(EDE)端点对 `999999` 不重试**——重放一次已计费的查询没有意义(该码 2026-08-01 前还兼表「查询无数据」,现在无数据是保留行列的占位单元格(统一 `null`),空表另表示整轴 code 未识别)。**终态码 `999011`(凭证无效)/ `140002`(异步生成失败)在任何 HTTP 状态下都不重试**——凭证错不会因重试而变,异步生成失败是终态。
264
+
265
+ <!-- no-replay-endpoints
266
+ 上面那句点名的「不重放」端点,完整清单如下(endpoint key,与 `gangtise raw list` 一致):
267
+ ai.earnings-review.get-id
268
+ ai.hot-topic
269
+ ai.investment-logic
270
+ ai.knowledge-batch
271
+ ai.management-discuss-announcement
272
+ ai.management-discuss-earnings-call
273
+ ai.one-pager
274
+ ai.peer-comparison
275
+ ai.research-outline
276
+ ai.theme-tracking
277
+ ai.viewpoint-debate.get-id
278
+ alternative.concept-info
279
+ alternative.concept-securities
280
+ insight.foreign-report.download
281
+ insight.pamirs-summary.download
282
+ insight.summary.download
283
+ tool.file-parse.submit
284
+ vault.my-conference.download
285
+ -->
267
286
  - **Token 自愈**:调用返回 `0000001008` / `999002` 时自动强制刷新 Token 并重试一次。
268
287
  - **K线/资金流向自动分片**:`quote day-kline --security aShares|hkStocks|usStocks`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、港股 2 天/片;已下线的 `day-kline-hk`/`day-kline-us`/`index-day-kline` 用 `all`,分别 2/1/15 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
269
288
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
@@ -304,20 +323,27 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
304
323
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
305
324
  - 安全上限:自动翻页最多 1000 页,防止异常循环
306
325
  - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
307
- - **`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` 硬失败
308
- - **分页端点返回 `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 个字节**。
309
- - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:`insight opinion` / `foreign-opinion` / `independent-opinion` 三个端点的 `total` 恒为 `10000`,而实际记录远不止(把 `from` 加到远超该值仍能取到真实记录)。省略 `--size` 的全量拉取本来会**正好取满 10000 条就停、且不报任何异常**——导出的文件是截断的却看不出来。现在全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
326
+ - **`indicator` 命令的退出码 3**(脚本按 `!= 0` 判失败的需留意):服务端整指标/整证券没返回时标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3。**2026-08-15 起这个分支基本收不到样本**——服务端现在对解析不了的代码直接报 `100003` 并点名是哪个(指标码拼错 →「指标 xxx 不存在」;证券后缀错,如美股写成 `AAPL.US` 而非 `AAPL.O` →「xxx 不是有效证券或者板块ID」),**无论同批有没有正确的代码都会报**,CLI 相应退出 1。真实的无数据/无覆盖仍是占位单元格 + 退出码 0。占位值统一是 `null`。⚠️ **报告期类指标(`is_*`)的时序上大部分行都是占位**(只有报告期末那几行是真值),`null` 虽被 Excel / pandas / SQL 的聚合跳过,**但行数不变**,手工「总和 ÷ 行数」仍会差几十倍;详见 skill 的 `references/commands/indicator.md`。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。⚠️ 这一档以「服务端返回了命中行」为前提;**零命中时一律退出码 0**(没有行需要被质疑),所以空集不能直接当成「无标的符合条件」——另有两种成因产生**逐字相同**的输出:**日期没落在报告期末**(报告期类指标此时整批 `null`),或**该指标不覆盖这批证券**(如拿 A 股专属指标查港美股)。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整/`1` 硬失败
327
+ - **分页端点返回 `null` 也退出 3**:分页端点的正常响应是 `{total, list}`,真实的空结果是 `{total: 0, list: []}`。若响应体是 `null`,CLI 在 stderr 告警并**退出码 3**——只给告警的话,脚本无法区分「这个筛选确实没命中」和「这个筛选没生效」。机器格式(jsonl/csv)此时 **stdout 不输出任何字节**(不是空行),`--format json` 仍忠实打印 `null`。⚠️ 带 `--output` 时文件仍会被创建:csv 会写入 3 字节 UTF-8 BOM(Excel 兼容用),jsonl 为 0 字节——**按文件大小判空的脚本要留意 csv 的这 3 个字节**。
328
+ - 🔴 **`total` 被服务端封顶时会标 `totalCapped` 并退出 3**:分页端点的 `total` 若被服务端封顶(返回一个固定上限而非真实条数),省略 `--size` 的全量拉取会**正好取满那个上限就停、且不报任何异常**——导出的文件是截断的却看不出来。现在全量拉取结束后会**多探一行**(`from = total`):探到数据就标 `partial` + `totalCapped` 并退出 3。判据不写死 10000,服务端改配置仍然有效;`total` 诚实时探针返回空、不产生计费。传了 `--size` 的有界请求不做此探测。
310
329
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
311
330
 
312
331
  ## 智能文件命名
313
332
 
314
333
  下载命令(`summary download`、`pamirs-summary download`、`research download`、`foreign-report download`、`announcement download`、`announcement-hk download`、`announcement-us download`、`official-account download`、`performance-calendar download`、`vault drive-download`、`vault record-download`、`vault my-conference-download`)省略 `--output` 时,自动使用真实标题作为文件名:
315
334
 
316
- 1. **缓存优先** — 如果之前执行过对应的 `list` 命令,标题已缓存在 `~/.config/gangtise/title-cache.json`,直接使用,无额外 API 调用
317
- 2. **API 回查** — 缓存未命中时,自动查询最近 200 条记录匹配标题
318
- 3. **兜底** — 都找不到时使用服务器返回的原始文件名或 `{type}-{id}.{ext}`
335
+ 1. **缓存优先** — 如果之前执行过对应的 `list` 命令,标题已缓存在 `~/.config/gangtise/title-cache.json`,直接使用,**无额外 API 调用、无额外积分**
336
+ 2. **兜底** — 缓存未命中时使用服务器返回的原始文件名,无则 `{type}-{id}.{ext}`
319
337
 
320
- 推荐工作流:先 `list` 再 `download`,文件名自动正确。
338
+ 推荐工作流:先 `list` 再 `download`,文件名自动正确且零额外成本。
339
+
340
+ 🔴 **缓存未命中时不会自动回查 list 接口**(v0.37.0 起)。回查要拉最近 200 条记录(4 次请求),而上面 12 个命令里有 9 个的 list 按 **0.1 积分/条**计费,约 20 积分——这笔开销只用于取一个更易读的文件名(下载本身 10–50 积分),所以改成按需开启。需要时加 `--resolve-title`:
341
+
342
+ ```bash
343
+ gangtise insight research download --report-id 432092410345574400 --resolve-title
344
+ ```
345
+
346
+ `--resolve-title` 取回的 200 条标题会一并写入缓存,所以同一批后续的下载不再重复回查。带 `--output` 时该参数无效(文件名已由你指定)。
321
347
 
322
348
  ## 常用示例
323
349
 
@@ -350,10 +376,13 @@ gangtise insight pamirs-summary list --keyword PCB --search-type 2 --rank-type 2
350
376
  gangtise insight pamirs-summary download --summary-id 5863771 --file-type 2
351
377
  # → PCB钻针:高端钻针扩产有壁垒,供需紧缺会持续到28年.html
352
378
 
353
- # 下载:先 list 再 download,自动使用真实标题作为文件名
379
+ # 下载:先 list 再 download,标题已在缓存里,文件名自动正确且零额外成本
354
380
  gangtise insight summary download --summary-id 4902586
355
381
  # → 超颖电子:2026年4月7日投资者关系活动记录表.txt
356
382
 
383
+ # 没先跑过 list(缓存未命中)时,默认退回 ID 文件名;要标题就显式回查
384
+ gangtise insight summary download --summary-id 4902586 --resolve-title
385
+
357
386
  # 下载 Markdown 版本
358
387
  gangtise insight research download --report-id 432092410345574400 --file-type 2
359
388
  # 下载外资研报中文翻译版
@@ -515,9 +544,8 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
515
544
  gangtise ai knowledge-batch --query 新能源汽车 --resource-type 10 --resource-type 11 --top 10
516
545
  gangtise ai security-clue --start-time "2026-04-01 00:00:00" --end-time "2026-04-09 23:59:59" --query-mode byIndustry --gts-code 821035.SWI --source researchReport --source announcement
517
546
  gangtise ai one-pager --security-code 600519.SH
518
- # 个股看点(精炼投研总结,仅 A 股/港股):传具体代码,或 aShares/hkStocks 拉全市场
547
+ # 个股看点(精炼投研总结,仅 A 股/港股):只收具体代码,单次最多 6000 个;不支持全市场关键字
519
548
  gangtise ai stock-summary --security 600519.SH --security 00700.HK --format json
520
- gangtise ai stock-summary --security hkStocks --format json
521
549
  gangtise ai investment-logic --security-code 600519.SH
522
550
  gangtise ai peer-comparison --security-code 600519.SH
523
551
  gangtise ai earnings-review --security-code 600519.SH --period 2025q3
@@ -707,11 +735,34 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
707
735
  - `--from`:非负整数
708
736
  - `--size` / `--limit` / `--top`:正整数
709
737
  - `--file-type` / `--resource-type` 以及数值型列表参数:有限数字
710
- - 所有 date 参数(`--start-date`/`--end-date`/`--date`/`--report-date`,含 Quote/Fundamental/AI/Alternative/Indicator):`YYYY-MM-DD`(严格——年在后等歧义格式在发请求前拒绝)
711
- - 所有 `--start-time` / `--end-time`(Insight/Vault/AI 透传、`quote minute-kline`,以及 A 股公告 / `knowledge-batch` 两个转换端点):`YYYY-MM-DD[ HH:mm[:ss]]`(时间部分秒可省、空格或 `T` 分隔)或 10/13 位 Unix 时间戳(严格校验——年在后等歧义格式在发请求前拒绝)
738
+ - 所有 date 参数(`--start-date`/`--end-date`/`--date`/`--report-date`,含 Quote/Fundamental/AI/Alternative/Indicator):`YYYY-MM-DD`、`YYYY/MM/DD` 或 `YYYYMMDD`,统一归一成 `YYYY-MM-DD` 发出(年在后等歧义写法在发请求前拒绝,见下节)
739
+ - 所有 `--start-time` / `--end-time`(Insight/Vault/AI 透传、`quote minute-kline`,以及 A 股公告 / `knowledge-batch` 两个转换端点):上述三种日期写法 + 可选的 `[ HH:mm[:ss]]`(秒可省、空格或 `T` 分隔),或 10/13 位 Unix 时间戳(同样归一日期部分、拒绝年在后写法)
712
740
 
713
741
  校验失败会输出 `ValidationError: Invalid ...` 并以非 0 状态退出。
714
742
 
743
+ ### 关于日期格式
744
+
745
+ **CLI 接受三种「年在前」写法,并统一归一成 `YYYY-MM-DD` 再发出:**
746
+
747
+ | 你写的 | 发出去的 |
748
+ |--------|---------|
749
+ | `2026-07-01` | `2026-07-01` |
750
+ | `2026/07/01` | `2026-07-01` |
751
+ | `20260701` | `2026-07-01` |
752
+
753
+ datetime 参数(`--start-time` / `--end-time`)同理,只归一日期部分:`2026/07/01 09:30:00` → `2026-07-01 09:30:00`;Unix 时间戳原样透传。
754
+
755
+ **「年在后」的写法(`07-01-2026`、`01/07/2026`)会被本地拒绝**,因为它对不同人意思不同:
756
+
757
+ | 写法 | 美式读法 | 欧洲读法 | 平台实际按 |
758
+ |------|---------|---------|-----------|
759
+ | `01-07-2026` | 1 月 7 日 | 7 月 1 日 | **1 月 7 日**(美式) |
760
+ | `07-01-2026` | 7 月 1 日 | 1 月 7 日 | **7 月 1 日**(美式) |
761
+
762
+ 平台接口本身会解析年在后写法,**一律按美式「月在前」**。所以按欧洲/国际习惯用 `01-07-2026` 表示「7 月 1 日」的话,会拿到 1 月 7 日的数据——请求返回 200、行数看着也正常,**不会有任何报错提示**。CLI 在发请求前就拒掉这类写法(不发请求、不计费,报错直接给出可用的格式),所以经 CLI 调用不会踩到这个坑。
763
+
764
+ ⚠️ **绕过 CLI 直接调 HTTP 接口时,请统一使用 `YYYY-MM-DD`。**
765
+
715
766
  ## 常见错误
716
767
 
717
768
  | 错误/错误码 | 说明 |
@@ -727,7 +778,7 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
727
778
  | `999010` | 接口地址不存在(`raw call` 的 key 可能已下线,用 `raw list` 核对) |
728
779
  | `999012` / `999013` / `999014` | 账号禁用 / 已过期 / 租户失效 |
729
780
  | `999016` | 调用方 IP 不在允许范围 |
730
- | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点的「无数据」已不再用此码——有效 code 无数据返回占位单元格(多数 `null`、个别指标 `0`),此码基本只剩真故障) |
781
+ | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点的「无数据」已不再用此码——有效 code 无数据返回占位单元格(统一 `null`),此码基本只剩真故障) |
731
782
  | `140002` | 终态失败:AI 异步生成失败,或 `indicator` 的参数/表达式错误(枚举越界、语法错)——改参数重提,不重试 |
732
783
  | `100003` | 参数值非法——**最宽的兜底码**;msg 通常已指明字段(如「limit 最小为 1,最大为 10000」),先读 msg |
733
784
  | `100001` | 缺必填参数(msg 带字段名,如「缺少必填参数: reportId」) |
package/dist/src/cli.js CHANGED
@@ -56,6 +56,9 @@ function addDownloadCommand(parent, spec) {
56
56
  }
57
57
  if (spec.contentTypeDescription)
58
58
  cmd.requiredOption("--content-type <type>", spec.contentTypeDescription);
59
+ if (spec.titleListEndpoint) {
60
+ cmd.option("--resolve-title", "On a title-cache miss, query the list endpoint for a friendly filename (4 extra requests; most of these list endpoints bill per row). Without it the server filename or <prefix>-<id> is used. Ignored when --output is given");
61
+ }
59
62
  cmd.option("--output <path>").action((options) => withClient(async (client) => {
60
63
  const id = options[spec.idField];
61
64
  const qp = { [spec.idField]: id };
@@ -67,7 +70,9 @@ function addDownloadCommand(parent, spec) {
67
70
  await runDownload(client, spec.endpointKey, qp, {
68
71
  output: options.output,
69
72
  fallbackName: `${spec.fallbackPrefix}-${id}`,
70
- resolveOutputPath: titleList ? (result) => resolveTitle(client, result, titleList, spec.idField, id) : undefined,
73
+ resolveOutputPath: titleList
74
+ ? (result) => resolveTitle(client, result, titleList, spec.idField, id, { allowLookup: Boolean(options.resolveTitle) })
75
+ : undefined,
71
76
  });
72
77
  }));
73
78
  }
@@ -300,7 +305,7 @@ addTimeFilters(announcementUs.command("list").addOption(new Option("--search-typ
300
305
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
301
306
  }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
302
307
  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" });
303
- 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 -- only 'gl' returns rows on this endpoint; cn/us/jp come back as a null payload with no error. 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", {
308
+ 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 -- this endpoint accepts only cn/cnHk/cnTw/us/jp/uk; the other 13 values of regionCategory (sea/gl/fr/de/kr/in/ca/me/othAs/othEur/latAm/oce/af) are rejected here with 100005 though they all work on foreign-report", collectList, []).option("--industry <id>", "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", 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", {
304
309
  from: parseFrom(options.from), size: parseSize(options.size),
305
310
  startTime: options.startTime, endTime: options.endTime,
306
311
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -309,7 +314,7 @@ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type
309
314
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker),
310
315
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
311
316
  })));
312
- 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", {
317
+ 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 -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", 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", {
313
318
  from: parseFrom(options.from), size: parseSize(options.size),
314
319
  startTime: options.startTime, endTime: options.endTime,
315
320
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -421,7 +426,7 @@ quote.command("realtime").description("Realtime quote snapshot (A-share / HK / U
421
426
  checkMarketKeywords(options.security, REALTIME_MARKETS, "quote realtime");
422
427
  return emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) }));
423
428
  });
424
- 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) => {
429
+ quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 920982.BJ), or 'aShares' for full A-share market — auto-sharded by day (repeat)", collectList, []).option("--start-date <date>", "Start date yyyy-MM-dd (default: endDate minus 1 year)", dateArg("--start-date")).option("--end-date <date>", "End date yyyy-MM-dd (default: latest trading day)", dateArg("--end-date")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000; single-security cap — aShares auto-shards by day)").option("--field <field>", "Field, e.g. mainNetInflow/largeInflow/xlargeOutflow (repeat); omit for all", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
425
430
  checkMarketKeywords(options.security, FUND_FLOW_MARKETS, "quote fund-flow");
426
431
  options.security = canonicalizeMarketKeywords(options.security, FUND_FLOW_MARKETS);
427
432
  return withClient(async (client) => {
@@ -710,7 +715,7 @@ indicator.command("time-series").option("--indicator <code>", "Indicator code, e
710
715
  flagDropped(rows, data, options.security, options.indicator);
711
716
  await printData(rows, format, options.output);
712
717
  }));
713
- 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'. The cross-section no-date opt-out ('F1:' with nothing after the colon) is REJECTED here: an indicator sent with no parameters does not take part in the screen and the query comes back as 0 rows with no error, so indicators whose parameterList has no date key (the pty_* / scr_* families, div_cash_yr, pty_shr_reg) cannot be screened on at all — read them with 'indicator cross-section' and filter client-side", 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) => {
718
+ 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'. Bare 'F1:' (nothing after the colon) declares that the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_sctr, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'F1:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'F1:' + 'F1:scale=8' keeps the scale", 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) => {
714
719
  const format = parseOutputFormat(options.format);
715
720
  requireIndicatorScope(options.indicator, options.security);
716
721
  const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
@@ -82,27 +82,39 @@ export function parseChoiceList(values, optionName, allowed) {
82
82
  }
83
83
  return maybeArray(values);
84
84
  }
85
- const YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
85
+ const YEAR_FIRST_DATE = /^(\d{4})([-/]?)(\d{2})\2(\d{2})$/;
86
+ function normalizeYearFirstDate(value) {
87
+ const parts = YEAR_FIRST_DATE.exec(value);
88
+ return parts ? `${parts[1]}-${parts[3]}-${parts[4]}` : undefined;
89
+ }
86
90
  export function parseDateOption(value, optionName) {
87
- if (!YYYY_MM_DD.test(value)) {
88
- throw new ValidationError(`Invalid ${optionName}: expected YYYY-MM-DD, got "${value}" — only that form is forwarded as-is; on some endpoints other layouts (e.g. "07/01/2026") are read as a different day`);
91
+ const normalized = normalizeYearFirstDate(value);
92
+ if (normalized === undefined) {
93
+ throw new ValidationError(`Invalid ${optionName}: expected YYYY-MM-DD (YYYY/MM/DD and YYYYMMDD also accepted), got "${value}" — year-last layouts are refused because the API reads them month-first (US convention): "01-07-2026" means 7 January, not 1 July`);
89
94
  }
90
- const [year, month, day] = value.split("-").map(Number);
91
- const parsed = new Date(`${value}T00:00:00Z`);
95
+ const [year, month, day] = normalized.split("-").map(Number);
96
+ const parsed = new Date(`${normalized}T00:00:00Z`);
92
97
  if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
93
98
  throw new ValidationError(`Invalid ${optionName}: "${value}" is not a real calendar date`);
94
99
  }
95
- return value;
100
+ return normalized;
96
101
  }
97
102
  export function dateArg(optionName) {
98
103
  return (value) => parseDateOption(value, optionName);
99
104
  }
100
- const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
105
+ const LOCAL_DATETIME = /^(\d{4})([-/]?)(\d{2})\2(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
106
+ function normalizeDatetime(value) {
107
+ const parts = LOCAL_DATETIME.exec(value);
108
+ if (!parts)
109
+ return value;
110
+ const [, y, sep, mo, d] = parts;
111
+ return sep === "" ? `${y}-${mo}-${d}${value.slice(8)}` : `${y}-${mo}-${d}${value.slice(10)}`;
112
+ }
101
113
  function datetimeFieldsValid(value) {
102
114
  const parts = LOCAL_DATETIME.exec(value);
103
115
  if (!parts)
104
116
  return false;
105
- const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
117
+ const [, y, , mo, d, hh = "0", mi = "0", ss = "0"] = parts;
106
118
  const year = Number(y), month = Number(mo), day = Number(d);
107
119
  if (month < 1 || month > 12)
108
120
  return false;
@@ -128,7 +140,7 @@ export function toTimestamp13(value) {
128
140
  if (!datetimeFieldsValid(value))
129
141
  return undefined;
130
142
  const parts = LOCAL_DATETIME.exec(value);
131
- const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
143
+ const [, y, , mo, d, hh = "0", mi = "0", ss = "0"] = parts;
132
144
  const year = Number(y);
133
145
  const dt = new Date(year, Number(mo) - 1, Number(d), Number(hh), Number(mi), Number(ss));
134
146
  const faithful = dt.getFullYear() === year && dt.getMonth() === Number(mo) - 1
@@ -141,15 +153,17 @@ export function parseTimestamp13(value, optionName) {
141
153
  return undefined;
142
154
  const parsed = toTimestamp13(value);
143
155
  if (parsed === undefined) {
144
- throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because Node and the API read their day/month order the opposite way round`);
156
+ throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator; YYYY/MM/DD and YYYYMMDD also accepted), got "${value}" — year-last layouts are refused because the API reads them month-first (US convention): "01-07-2026" means 7 January, not 1 July`);
145
157
  }
146
158
  return parsed;
147
159
  }
148
160
  export function parseDatetimeOption(value, optionName) {
149
- if (epochMillis(value) === undefined && !datetimeFieldsValid(value)) {
150
- throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because the API reads their day/month order differently per separator`);
161
+ if (epochMillis(value) !== undefined)
162
+ return value;
163
+ if (!datetimeFieldsValid(value)) {
164
+ throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator; YYYY/MM/DD and YYYYMMDD also accepted), got "${value}" — year-last layouts are refused because the API reads them month-first (US convention): "01-07-2026" means 7 January, not 1 July`);
151
165
  }
152
- return value;
166
+ return normalizeDatetime(value);
153
167
  }
154
168
  export function datetimeArg(optionName) {
155
169
  return (value) => parseDatetimeOption(value, optionName);
@@ -287,10 +301,9 @@ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
287
301
  if (indicators.has(field)) {
288
302
  throw new ValidationError(`Duplicate --indicator variable "${field}": each variable must bind exactly one indicator`);
289
303
  }
290
- indicators.set(field, { field, indicatorCode, parameters: params.get(field) ?? [] });
291
- }
292
- if (noQueryDate.size > 0) {
293
- throw new ValidationError(`--indicator-param "${[...noQueryDate][0]}:" (the no-date opt-out) is not supported by the screener: an indicator sent with no parameters does not take part in the screen, so it would run without that condition and answer 0 rows with no error. Read the value with 'indicator cross-section --indicator-param "<code>:"' and filter client-side.`);
304
+ indicators.set(field, noQueryDate.has(field)
305
+ ? { field, indicatorCode, parameters: params.get(field) ?? [], noQueryDate: true }
306
+ : { field, indicatorCode, parameters: params.get(field) ?? [] });
294
307
  }
295
308
  for (const field of params.keys()) {
296
309
  if (!indicators.has(field)) {
@@ -237,7 +237,7 @@ export class GangtiseClient {
237
237
  }
238
238
  const short = collected.length < target;
239
239
  let totalCapped = false;
240
- if (endpoint.retry !== "no-replay" && requestedSize === undefined && total > 0 && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
240
+ if (requestedSize === undefined && total > 0 && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
241
241
  try {
242
242
  const probe = await this.requestJson(endpoint, { ...initialBody, from: total, size: 1 });
243
243
  if (this.isPaginatedListResponse(probe) && probe.list.length > 0)
@@ -249,10 +249,6 @@ export class GangtiseClient {
249
249
  if (totalCapped) {
250
250
  process.stderr.write(`[gangtise] warning: ${endpoint.key} reported total=${total} but rows exist past that offset — 'total' is a server-side cap, not the real count. This export is TRUNCATED at ${collected.length} rows. Narrow the query (date range / filters) and fetch in slices.\n`);
251
251
  }
252
- if (endpoint.retry === "no-replay" && requestedSize === undefined && total > 0
253
- && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0 && isVerbose()) {
254
- process.stderr.write(`[gangtise] total-cap probe skipped on ${endpoint.key} (per-call billing) — a capped 'total' would go undetected here\n`);
255
- }
256
252
  const out = {
257
253
  ...firstPage,
258
254
  total,
@@ -382,6 +378,7 @@ export class GangtiseClient {
382
378
  url.searchParams.set(key, String(value));
383
379
  });
384
380
  const authState = { retried: false };
381
+ const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
385
382
  return withRetry(async () => {
386
383
  const authorization = await this.getAuthorizationHeader();
387
384
  const startedAt = Date.now();
@@ -394,8 +391,8 @@ export class GangtiseClient {
394
391
  ? { Authorization: authorization, 'content-type': 'application/json' }
395
392
  : { Authorization: authorization },
396
393
  body: isPost ? JSON.stringify(body ?? {}) : undefined,
397
- headersTimeout: this.config.timeoutMs,
398
- bodyTimeout: this.config.timeoutMs,
394
+ headersTimeout: timeoutMs,
395
+ bodyTimeout: timeoutMs,
399
396
  dispatcher,
400
397
  });
401
398
  for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
@@ -411,8 +408,8 @@ export class GangtiseClient {
411
408
  response = await request(currentUrl, {
412
409
  method: 'GET',
413
410
  headers: auth ? { Authorization: auth } : {},
414
- headersTimeout: this.config.timeoutMs,
415
- bodyTimeout: this.config.timeoutMs,
411
+ headersTimeout: timeoutMs,
412
+ bodyTimeout: timeoutMs,
416
413
  dispatcher,
417
414
  });
418
415
  }
@@ -1,4 +1,5 @@
1
1
  import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators, parseSize } from "./args.js";
2
+ import { ValidationError } from "./errors.js";
2
3
  export function buildQuoteKlineBody(options) {
3
4
  return {
4
5
  securityList: maybeArray(options.security),
@@ -35,6 +36,16 @@ export function buildStockPoolStocksBody(options) {
35
36
  };
36
37
  }
37
38
  const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate"]);
39
+ function assertParamCodesBound(groups, codes) {
40
+ if (!groups?.length)
41
+ return;
42
+ const bound = new Set(codes);
43
+ for (const group of groups) {
44
+ if (!bound.has(group.indicatorCode)) {
45
+ throw new ValidationError(`--indicator-param references "${group.indicatorCode}", which no --indicator names`);
46
+ }
47
+ }
48
+ }
38
49
  function withQueryDate(groups, codes, date) {
39
50
  const merged = new Map();
40
51
  for (const group of groups ?? [])
@@ -55,15 +66,19 @@ function stripMarker(item) {
55
66
  return rest;
56
67
  }
57
68
  export function buildIndicatorCrossSectionBody(options) {
69
+ const groups = parseIndicatorParams(options.indicatorParam);
70
+ assertParamCodesBound(groups, options.indicator);
58
71
  return {
59
72
  indicatorCodeList: maybeArray(options.indicator),
60
73
  universe: maybeArray(options.security),
61
74
  currency: options.currency,
62
75
  scale: options.scale,
63
- indicatorParamList: withQueryDate(parseIndicatorParams(options.indicatorParam), options.indicator, options.date),
76
+ indicatorParamList: withQueryDate(groups, options.indicator, options.date),
64
77
  };
65
78
  }
66
79
  export function buildIndicatorTimeSeriesBody(options) {
80
+ const groups = parseIndicatorParams(options.indicatorParam);
81
+ assertParamCodesBound(groups, options.indicator);
67
82
  return {
68
83
  indicatorCodeList: maybeArray(options.indicator),
69
84
  universe: maybeArray(options.security),
@@ -72,7 +87,7 @@ export function buildIndicatorTimeSeriesBody(options) {
72
87
  calendarType: options.calendarType,
73
88
  currency: options.currency,
74
89
  scale: options.scale,
75
- indicatorParamList: (parseIndicatorParams(options.indicatorParam) ?? []).map(stripMarker),
90
+ indicatorParamList: (groups ?? []).map(stripMarker),
76
91
  };
77
92
  }
78
93
  export function buildIndicatorScreenerBody(options) {
@@ -80,8 +95,8 @@ export function buildIndicatorScreenerBody(options) {
80
95
  return {
81
96
  universe: maybeArray(options.security),
82
97
  expression: options.expression,
83
- indicatorList: indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
84
- ? indicator
85
- : { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] })),
98
+ indicatorList: indicators.map((indicator) => (indicator.noQueryDate || indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
99
+ ? stripMarker(indicator)
100
+ : { ...stripMarker(indicator), parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] })),
86
101
  };
87
102
  }
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import { extname } from "node:path";
3
3
  import { DownloadError } from "./errors.js";
4
4
  import { saveOutputIfNeeded } from "./output.js";
5
- import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCache.js";
5
+ import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
6
6
  function sanitizeFilename(name) {
7
7
  return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
8
8
  }
@@ -60,7 +60,8 @@ export function extFromContentType(contentType) {
60
60
  const mime = contentType.split(";")[0].trim().toLowerCase();
61
61
  return MIME_EXT[mime] ?? "";
62
62
  }
63
- export async function resolveTitle(client, result, listEndpoint, idField, idValue, titleField = "title") {
63
+ export async function resolveTitle(client, result, listEndpoint, idField, idValue, options = {}) {
64
+ const titleField = options.titleField ?? "title";
64
65
  const file = result;
65
66
  const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
66
67
  function buildFilename(rawTitle) {
@@ -78,17 +79,26 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
78
79
  }
79
80
  catch {
80
81
  }
82
+ if (!options.allowLookup)
83
+ return undefined;
84
+ const previousExitCode = process.exitCode;
81
85
  try {
82
86
  const resp = await client.call(listEndpoint, { from: 0, size: TITLE_LOOKUP_SIZE });
83
- const items = Array.isArray(resp) ? resp : (resp.list ?? []);
84
- const match = items.find(item => String(item[idField]) === String(idValue));
85
- const rawTitle = match?.[titleField];
86
- if (typeof rawTitle === "string" && rawTitle)
87
+ const items = Array.isArray(resp) ? resp : (resp?.list ?? []);
88
+ const titles = extractTitles(items, { endpointKey: listEndpoint, idField, titleField });
89
+ if (Object.keys(titles).length > 0) {
90
+ await writeTitleCache(listEndpoint, titles).catch(() => { });
91
+ }
92
+ const rawTitle = titles[String(idValue)];
93
+ if (rawTitle)
87
94
  return buildFilename(rawTitle);
88
95
  }
89
96
  catch {
90
97
  return undefined;
91
98
  }
99
+ finally {
100
+ process.exitCode = previousExitCode;
101
+ }
92
102
  return undefined;
93
103
  }
94
104
  async function downloadUrlTo(url, outputPath) {
@@ -124,7 +124,7 @@ const MESSAGE_HINTS = [
124
124
  match: /不支持参数\s*(?:tradeDate|reportDate)/,
125
125
  notMatch: /缺少必填参数[::]?\s*(?:reportDate|tradeDate)/,
126
126
  when: (message) => distinctFailureShapes(message) <= 1,
127
- hint: "服务端只说这个键不该出现,**没说该换成哪个**——别照着「改用 reportDate」做,那多半同样被拒。报错点名的指标,其 parameterList 里没有 --date 下发的那个日期键,给每个被点名的指标各加一条 --indicator-param \"<指标code>:\"(冒号后留空)声明「本指标不要查询日期」即可,它与真实参数可以共存。跑 `indicator search --keyword <指标code> --format json` 确认还缺什么:parameterList 为空就只加这一条;还列了 fiscalYear 之类的就连同 --indicator-param \"<指标code>:fiscalYear=2025\" 一起给。⚠️ screener 上这个写法用不了(服务端会静默丢弃该指标并返 0 行),改用 `indicator cross-section` 取回来本地筛。",
127
+ hint: "服务端只说这个键不该出现,**没说该换成哪个**——别照着「改用 reportDate」做,那多半同样被拒。报错点名的指标,其 parameterList 里没有 --date 下发的那个日期键,给每个被点名的指标各加一条 --indicator-param \"<指标code>:\"(冒号后留空)声明「本指标不要查询日期」即可,它与真实参数可以共存。跑 `indicator search --keyword <指标code> --format json` 确认还缺什么:parameterList 为空就只加这一条;还列了 fiscalYear 之类的就连同 --indicator-param \"<指标code>:fiscalYear=2025\" 一起给。screener 上是同一个写法,只是按变量写:--indicator-param \"F1:\"。",
128
128
  },
129
129
  {
130
130
  codes: ["100001", "100003"],
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.35.0";
1
+ export const CLI_VERSION = "0.37.0";