gangtise-openapi-cli 0.30.0 → 0.31.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,12 +6,11 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
+ - **v0.31.0 — 2026-08-03**:修复 v0.30.1 的矩阵维度校验误杀「单指标 × 板块 ID」时序查询的回归;EDE 结果不完整或不可信时改以退出码 3 标记(`partial` / `unreliable`)。
10
+ - **v0.30.1 — 2026-08-02**:修复 `sDate` 吞掉查询日期导致的区间指标静默错数,让条件选股的文本筛选真正可用,并在服务端整行/整列丢数据时给出警告。
9
11
  - **v0.30.0 — 2026-08-02**:适配 EDE 接口重构(`universe` 取代 `securityCodeList`、截面矩阵转置、指标元数据结构化),新增 `indicator screener` 条件选股,并修正复权参数名。
10
12
  - **v0.29.0 — 2026-07-25**:新增财报日历与 PDF 解析工具,群消息补 `quoteMsg`,并加强大整数 ID 与高积分调用防护。
11
13
  - **v0.28.3 — 2026-07-24**:修复列式响应在错误 `--field` 下静默错列的问题,并校正 EDE 与字段文档。
12
- - **v0.28.2 — 2026-07-24**:EDE 批量取数新增 `--key-by name|code`,优化无数据诊断与相关 Skill 文档。
13
- - **v0.28.1 — 2026-07-23**:调整 Agent Skill 取数路由,多证券财务与估值指标优先使用 EDE 批量接口。
14
- - **v0.28.0 — 2026-07-21**:适配新版错误码,强化日期校验、异步状态处理、可观测性与计费安全。
15
14
 
16
15
  ### 历史里程碑
17
16
 
@@ -298,6 +297,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
298
297
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
299
298
  - 安全上限:自动翻页最多 1000 页,防止异常循环
300
299
  - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
300
+ - **v0.31.0 起退出码 3 的覆盖面扩大**(脚本按 `!= 0` 判失败的需留意):`indicator` 命令在服务端整指标/整证券无数据时同样标 `partial` + `omittedIndicators` / `omittedSecurities` 并退出 3;条件选股把同一指标绑到多个变量时标 `unreliable: true` + `duplicatedIndicators` 并退出 3(那些值不可信,不是少了行)。**条件选股的缺列另有更严的一档**:把缺列的变量当作无法求值,若表达式(按 `&&`/`||` 的布尔结构)再无任何可成立的分支,则**退出码 1 且不输出**——那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过。**整个查询合法无数据仍是退出码 0 且不标 partial**。语义约定:`0` 完整成功(含合法空结果)/`3` 有数据但不完整或不可信/`1` 硬失败
301
301
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
302
302
 
303
303
  ## 智能文件命名
@@ -576,12 +576,13 @@ gangtise indicator cross-section \
576
576
  --date 2026-07-31 --format table
577
577
  # 输出列:security / name / <各指标名>…(v0.30.0 起无 date 列——日期挂在每个指标的参数上)
578
578
 
579
- # 时间序列:多指标 × 单证券 或 单指标 × 多证券(不能多 × 多,否则报 410001)
579
+ # 时间序列:多指标 × 单证券 或 单指标 × 多证券(不能多 × 多,否则报 100003)
580
580
  gangtise indicator time-series --indicator qte_close \
581
581
  --security 600519.SH --security 09992.HK \
582
582
  --start-date 2026-07-29 --end-date 2026-07-31 --format table
583
583
 
584
584
  # 条件选股:F1/F2… 绑定指标,用表达式组合筛选(--indicator-param 按变量索引,不是按 code)
585
+ # --date 必填:绝大多数指标吃 tradeDate,漏传就是空表 + 退出码 0
585
586
  gangtise indicator screener \
586
587
  --indicator F1:qte_mkt_cptl --indicator F2:finc_pe_ttm \
587
588
  --indicator-param "F1:scale=8" \
@@ -589,16 +590,25 @@ gangtise indicator screener \
589
590
  --expression "F1 >= 500 && F2 <= 30" \
590
591
  --date 2026-07-31 --format table
591
592
 
593
+ # 文本筛选:经营范围含「酒」(contains/notcontains 只对 string 类型指标有效)
594
+ gangtise indicator screener --indicator F1:pty_op_scope \
595
+ --security 1000000287 --expression "F1 contains '酒'" \
596
+ --date 2026-07-31 --format table
597
+
592
598
  # 复权 / 指标专属参数用 --indicator-param "code:key=value"
593
599
  # ⚠️ 参数名必须以 search 的 parameterList 为准:传错名服务端静默忽略、退回默认值,不报错
594
600
  gangtise indicator cross-section --indicator qte_close --security 600519.SH \
595
601
  --date 2024-01-02 --indicator-param "qte_close:adjustType=3" # 1不复权/2前复权/3后复权/4定点
596
602
  # 不复权 1685.01 → 前复权 1531.225 → 后复权 13609.6168(前复权在最新交易日等于不复权,验证要用历史日)
597
603
 
598
- # 必填参数:很多指标默认调用报 410106(缺必填参数),按 parameterList 的 required 补齐再取:
599
- # N 期统计补 periodNum、区间/周期类(如 qte_amp_mo 月振幅)补 startDate、年度/分红类补 fiscalYear
604
+ # 必填参数:部分指标缺必填参数会报 140002,按 parameterList 的 required 补齐再取:
605
+ # N 期统计补 periodNum、区间类补 sDate(起始日,tradeDate 仍是终点)、年度/分红类补 fiscalYear
600
606
  gangtise indicator cross-section --indicator finc_roe_avg_avg --security 600519.SH \
601
607
  --date 2026-03-31 --indicator-param "finc_roe_avg_avg:periodNum=4"
608
+
609
+ # 区间指标:sDate 是起点、--date 下发的 tradeDate 是终点,两者共存
610
+ gangtise indicator cross-section --indicator qte_vol_intvl --security 600519.SH \
611
+ --date 2024-01-31 --indicator-param "qte_vol_intvl:sDate=2024-01-02"
602
612
  ```
603
613
 
604
614
  ### Alternative(行业指标数据库 EDB)
@@ -696,6 +706,7 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
696
706
  | `999012` / `999013` / `999014` | 账号禁用 / 已过期 / 租户失效 |
697
707
  | `999016` | 调用方 IP 不在允许范围 |
698
708
  | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点无数据已改为返回空表,此码基本只剩真故障) |
709
+ | `140002` | 终态失败:AI 异步生成失败,或 `indicator` 的参数/表达式错误(枚举越界、语法错)——改参数重提,不重试 |
699
710
  | `100003` | 参数值非法——**最宽的兜底码**;msg 通常已指明字段(如「limit 最小为 1,最大为 10000」),先读 msg |
700
711
  | `100001` | 缺必填参数(msg 带字段名,如「缺少必填参数: reportId」) |
701
712
  | `100006` | 查询/下载数量超限——**取代旧 `430007`** |
package/dist/src/cli.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { Command, Option } from "commander";
3
3
  import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
4
  import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
- import { collectKeyValue, collectList, collectNumberList, dateArg, datetimeArg, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
5
+ import { collectKeyValue, collectList, collectNumberList, dateArg, datetimeArg, duplicateScreenerCodes, 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
- import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
7
+ import { checkScreenerBindings, droppedFromMatrix, flattenCrossSection, flattenTimeSeries, isEmptyMatrix, requireIndicatorMatrix, unwrapIndicatorData } from "./core/indicatorMatrix.js";
8
8
  import { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
9
9
  import { loadConfig } from "./core/config.js";
10
10
  import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
@@ -116,7 +116,7 @@ function addTimeFilters(command) {
116
116
  .option("--end-time <datetime>", "End time", datetimeArg("--end-time"))
117
117
  .option("--keyword <keyword>", "Keyword");
118
118
  }
119
- import { setVerbose } from "./core/transport.js";
119
+ import { isVerbose, setVerbose } from "./core/transport.js";
120
120
  import { CLI_VERSION } from "./version.js";
121
121
  const program = new Command();
122
122
  program
@@ -697,7 +697,67 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
697
697
  alternative.command("concept-info").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-info", { conceptId: options.conceptId })));
698
698
  alternative.command("concept-securities").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-securities", { conceptId: options.conceptId })));
699
699
  program.addCommand(alternative);
700
- const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series");
700
+ /** Mark and report what the server left out. EDE does not pad missing data with
701
+ * `null`: an indicator empty for every security vanishes from `indicatorList`,
702
+ * a security empty for every indicator vanishes from `securityCodeList` (probed
703
+ * 2026-08-02). Without this the shortfall is invisible — exit code 0, a
704
+ * plausible-looking table, and a `--key-by code` mapping whose key simply is not
705
+ * there.
706
+ *
707
+ * This is the same class of defect as a failed page or a row cap, so it reuses
708
+ * the same signal: `partial` on the payload (printData → exit 3) plus the
709
+ * omitted codes, so an automated caller can react without parsing stderr. */
710
+ function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
711
+ // A wholly empty response is a legitimate answer, not a partial one: the diff
712
+ // against the request would list everything as "omitted", which is false —
713
+ // nothing was dropped, there is no data. Exit 0, but say why it is ambiguous.
714
+ if (isEmptyMatrix(data)) {
715
+ process.stderr.write("[gangtise] note: the query returned no data at all. That is a normal answer for a non-trading range or a date outside coverage — but since 2026-08-01 it is ALSO what a wrong parameter name or the wrong date field (tradeDate vs reportDate) produces. Cross-check against 'gangtise indicator search --format json'.\n");
716
+ return;
717
+ }
718
+ const { securities, indicators } = droppedFromMatrix(data, requestedSecurities, requestedIndicators);
719
+ if (securities.length === 0 && indicators.length === 0)
720
+ return;
721
+ if (rows && typeof rows === "object" && !Array.isArray(rows)) {
722
+ const rec = rows;
723
+ rec.partial = true;
724
+ if (indicators.length > 0)
725
+ rec.omittedIndicators = indicators;
726
+ if (securities.length > 0)
727
+ rec.omittedSecurities = securities;
728
+ }
729
+ const parts = [];
730
+ if (indicators.length > 0)
731
+ parts.push(`indicators ${indicators.join(", ")}`);
732
+ if (securities.length > 0)
733
+ parts.push(`securities ${securities.join(", ")}`);
734
+ process.stderr.write(`[gangtise] warning: the response omits ${parts.join(" and ")} entirely (no data for any counterpart, not null) — check scopeList coverage and the date semantics for those indicators. Result marked partial (exit 3).\n`);
735
+ }
736
+ /** Mark a screener result whose values cannot be trusted. Distinct from
737
+ * `partial`: no rows are missing — the numbers themselves may belong to the
738
+ * wrong variable. A duplicated indicator code is resolved from the EARLIEST date
739
+ * among its bindings, that single value lands in the first of their columns, and
740
+ * the rest come back null (probed 2026-08-03), so both the values shown and the
741
+ * set of matched securities are untrustworthy. */
742
+ function flagUnreliable(rows, duplicated) {
743
+ if (rows && typeof rows === "object" && !Array.isArray(rows)) {
744
+ const rec = rows;
745
+ rec.unreliable = true;
746
+ rec.duplicatedIndicators = duplicated;
747
+ }
748
+ }
749
+ /** `--indicator` / `--security` are repeatable, so Commander cannot mark them
750
+ * required — but every matrix endpoint needs at least one of each and answers a
751
+ * missing one with 100001, whose hint sends the user to `--help`, which in turn
752
+ * showed them as optional with a `[]` default. Catch it here: no request, no
753
+ * round trip, and a message that names the flag. */
754
+ function requireIndicatorScope(indicators, securities) {
755
+ const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
756
+ if (missing.length > 0) {
757
+ throw new ValidationError(`${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} required (repeat the flag for multiple values)`);
758
+ }
759
+ }
760
+ const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series, screener");
701
761
  indicator.command("search").requiredOption("--keyword <text>", "Search keyword, e.g. '收盘价' '成交量' '营业收入' (not free-form questions)").option("--limit <number>", "Max results (default: 50, max: 100)", "50").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
702
762
  const format = parseOutputFormat(options.format);
703
763
  const raw = await client.call("indicator.search", {
@@ -706,22 +766,83 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
706
766
  });
707
767
  await printData(unwrapIndicatorData(raw), format, options.output);
708
768
  }));
709
- indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (repeat; union, deduped)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate — report-period indicators need --indicator-param 'code:reportDate=...' instead", 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) => {
769
+ 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 need --indicator-param 'code:reportDate=...' instead", 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) => {
710
770
  const format = parseOutputFormat(options.format);
771
+ requireIndicatorScope(options.indicator, options.security);
711
772
  const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
712
- await printData(flattenCrossSection(unwrapIndicatorData(raw), options.keyBy), format, options.output);
773
+ const data = requireIndicatorMatrix(raw);
774
+ // Flatten first: a shape error must not be preceded by a dropped-rows warning
775
+ // that reads like the run merely came back short.
776
+ const rows = flattenCrossSection(data, options.keyBy);
777
+ flagDropped(rows, data, options.security, options.indicator);
778
+ await printData(rows, format, options.output);
713
779
  }));
714
- indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (repeat; union, deduped)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday (default TD)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode/securityCode, unique & order-stable for batch mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
780
+ indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday (default TD)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode/securityCode, unique & order-stable for batch mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
715
781
  const format = parseOutputFormat(options.format);
782
+ requireIndicatorScope(options.indicator, options.security);
716
783
  const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
717
- await printData(flattenTimeSeries(unwrapIndicatorData(raw), options.keyBy), format, options.output);
784
+ const data = requireIndicatorMatrix(raw);
785
+ // Pass the universe itself, not a count: flattenTimeSeries needs to know
786
+ // whether a sector ID is in play (the server expands it, so one entry can mean
787
+ // many securities) and dedupes internally. The request only breaks the tie when
788
+ // the response is 1×1 anyway. Flatten before flagging so a shape error surfaces
789
+ // on its own.
790
+ const rows = flattenTimeSeries(data, options.keyBy, options.security);
791
+ flagDropped(rows, data, options.security, options.indicator);
792
+ await printData(rows, format, options.output);
718
793
  }));
719
- 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 (repeat)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (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").option("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate unless it already has one", 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) => {
794
+ 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) => {
720
795
  const format = parseOutputFormat(options.format);
796
+ requireIndicatorScope(options.indicator, options.security);
797
+ const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
798
+ const duplicated = duplicateScreenerCodes(bindings);
799
+ if (duplicated.length > 0) {
800
+ process.stderr.write(`[gangtise] warning: ${duplicated.join(", ")} bound to more than one variable — the API allows this, but the server currently resolves ALL of those bindings against the EARLIEST date among them, puts that single value in the first of their columns, and leaves the rest null (probed 2026-08-03). So the surviving number is not necessarily the variable it is labelled with: a variable asking for 07-31 came back holding 07-30's price. Every part of the result is affected — the values shown, and which securities matched at all, since conditions on the null variables filtered on nothing. The same request also returns an empty set about a third of the time. Treat the WHOLE result as unusable and split into separate calls until it is fixed.\n`);
801
+ }
721
802
  const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
722
803
  // Same payload shape as cross-section (one row per matched security, one
723
- // column per indicator) with a `field` on each indicator entry.
724
- await printData(flattenCrossSection(unwrapIndicatorData(raw), options.keyBy), format, options.output);
804
+ // column per indicator) with a `field` on each indicator entry. No dropped-row
805
+ // flag here: a security missing from a screener result means it failed the
806
+ // filter, which is the whole point.
807
+ const data = requireIndicatorMatrix(raw);
808
+ // Before anything is rendered: the returned bindings must be the ones that
809
+ // were asked for, and the expression must still be evaluable from the columns
810
+ // that came back — otherwise the rows cannot be shown to satisfy the
811
+ // conditions they claim to. See checkScreenerBindings for how a missing column
812
+ // is weighed against the expression's boolean structure.
813
+ // Flatten first: it asserts the payload's structural axes, and a response
814
+ // missing `indicatorList` outright deserves that diagnosis rather than being
815
+ // reported as a binding problem. Nothing renders in between, so ordering the
816
+ // structural check ahead of the semantic one is free.
817
+ const rows = flattenCrossSection(data, options.keyBy);
818
+ const filteredOn = screenerExpressionFields(options.expression);
819
+ const unbound = checkScreenerBindings(data, bindings, options.expression);
820
+ // Same ambiguity as the other matrix commands: an empty screen is a normal
821
+ // answer AND what a wrong parameter name produces. Keyed on "nothing matched"
822
+ // rather than the strict canonical-empty shape — a response that returns zero
823
+ // securities while still echoing `indicatorList` is just as empty to the
824
+ // caller, and just as ambiguous, but would slip past isEmptyMatrix.
825
+ // flattenCrossSection above already asserted this is an array of non-empty
826
+ // strings, so only its length is left to read.
827
+ if (data.securityCodeList.length === 0) {
828
+ process.stderr.write("[gangtise] note: nothing matched the expression. That is a normal answer — but since 2026-08-01 an empty result is ALSO what a wrong parameter name or the wrong date field produces. Cross-check the indicator parameters against 'gangtise indicator search --format json'.\n");
829
+ }
830
+ if (unbound.length > 0) {
831
+ // Whatever reached here still leaves the expression evaluable (or was never
832
+ // read by it), so the rows stand: losing the column costs information, not
833
+ // correctness, and it degrades rather than failing.
834
+ const rec = rows;
835
+ rec.partial = true;
836
+ rec.omittedIndicators = unbound.map((field) => bindings.find((b) => b.field === field)?.indicatorCode ?? field);
837
+ const alsoFiltered = unbound.filter((field) => filteredOn.includes(field));
838
+ const filterNote = alsoFiltered.length > 0
839
+ ? ` The expression also filters on ${alsoFiltered.join(", ")}, so that condition was applied to none of these rows — another branch of the expression could still have matched them legitimately, but verify before relying on that filter.`
840
+ : "";
841
+ process.stderr.write(`[gangtise] warning: ${unbound.join(", ")} produced no column at all (no data for any matched security) — those output values are missing.${filterNote} Result marked partial (exit 3).\n`);
842
+ }
843
+ if (duplicated.length > 0)
844
+ flagUnreliable(rows, duplicated);
845
+ await printData(rows, format, options.output);
725
846
  }));
726
847
  program.addCommand(indicator);
727
848
  const tool = new Command("tool").description("Research tool APIs: PDF parsing");
@@ -802,12 +923,64 @@ async function checkForUpdate(timeoutMs = 2000) {
802
923
  }
803
924
  catch { /* best-effort: offline or a slow registry must not break --version */ }
804
925
  }
805
- // `gangtise ... | head` closes stdout early; without a handler the final big write
806
- // crashes Node with an unhandled 'error' event. Exit quietly like a normal CLI.
926
+ /** Last-resort reporting for anything that escapes main()'s try/catch: an error
927
+ * thrown inside an event callback or a rejected promise nobody awaited. Node's
928
+ * default is a multi-line crash dump on stdout/stderr AND a non-zero exit — so a
929
+ * command whose data was already printed correctly would end up looking like a
930
+ * hard failure, with a stack trace where a message belongs. This release is
931
+ * about exit codes meaning what they say; that is the one path that lies.
932
+ *
933
+ * The stack is kept behind --verbose: reaching here at all means a CLI bug
934
+ * rather than an API failure, and one line is not enough to locate one. */
935
+ /** Milliseconds to let already-queued stdout reach the pipe before leaving. */
936
+ const FATAL_FLUSH_MS = 200;
937
+ let leaving = false;
938
+ function reportFatal(error) {
939
+ // `error.stack` already opens with "Name: message" — printing both duplicates
940
+ // the first line.
941
+ const stack = error instanceof Error ? error.stack : undefined;
942
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
943
+ const text = `${isVerbose() && stack ? stack : message}\n`;
944
+ if (leaving) {
945
+ process.stderr.write(text);
946
+ return;
947
+ }
948
+ leaving = true;
949
+ process.exitCode = 1;
950
+ // Terminating is not optional: a fatal error with a live handle (a timer, an
951
+ // open socket) would otherwise keep the process running forever on `exitCode`
952
+ // alone. But `process.exit()` on the spot truncates whatever is still queued —
953
+ // to a pipe BOTH streams are written asynchronously — and a handler meant to
954
+ // stop this release from lying about exit codes must not start dropping output
955
+ // to do it. So wait for the diagnostic itself AND for anything stdout still
956
+ // owes, each bounded by the same deadline.
957
+ //
958
+ // stderr is not merely "check the length": the write is issued right here, so
959
+ // its callback is the only thing that knows when it actually reached the pipe.
960
+ // Exiting on the spot truncated a large diagnostic to one 64 KiB buffer.
961
+ let pending = 1;
962
+ const leave = () => { if (--pending === 0)
963
+ process.exit(1); };
964
+ if (process.stdout.writableLength > 0) {
965
+ pending++;
966
+ process.stdout.once("drain", leave);
967
+ }
968
+ setTimeout(() => process.exit(1), FATAL_FLUSH_MS);
969
+ process.stderr.write(text, leave);
970
+ }
971
+ process.on("uncaughtException", reportFatal);
972
+ process.on("unhandledRejection", reportFatal);
973
+ /** Teardown races on a closed stdout: the reader went away, which is not this
974
+ * process's failure. `gangtise ... | head` is the everyday case — it truncates
975
+ * the output and still exits 0, so a same-class race has no principled reason to
976
+ * exit 1. Without a handler the final write crashes Node with an unhandled
977
+ * 'error' event; rethrowing from this callback did the same by another route
978
+ * (a crash dump appended AFTER the correct JSON had already been written). */
979
+ const READER_GONE = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "EBADF"]);
807
980
  process.stdout.on("error", (error) => {
808
- if (error?.code === "EPIPE")
981
+ if (error?.code && READER_GONE.has(error.code))
809
982
  process.exit(0);
810
- throw error;
983
+ reportFatal(error);
811
984
  });
812
985
  async function main() {
813
986
  // Positional check, not argv.includes: "--version" appearing later (e.g. as
@@ -283,11 +283,110 @@ export function parseIndicatorParams(specs) {
283
283
  * else, and a typo'd variable would otherwise surface as an opaque expression
284
284
  * error rather than pointing at the binding that is wrong. */
285
285
  const SCREENER_FIELD = /^F[1-9][0-9]*$/;
286
+ /** Variable references inside a screener expression, e.g. the F1/F2 in
287
+ * `F1 >= 800 && F2 <= 30`. String literals are stripped first, so a
288
+ * `contains 'F2 系列'` operand is not mistaken for a reference. */
289
+ const SCREENER_FIELD_REF = /\bF[1-9][0-9]*\b/g;
290
+ const SCREENER_STRING_LITERAL = /'[^']*'|"[^"]*"/g;
291
+ /** Split an expression into `(`, `)`, `&&`, `||` and the atoms between them.
292
+ * String literals are copied verbatim so a `||` or an `F2` inside one is never
293
+ * mistaken for syntax. */
294
+ function tokenizeScreenerExpression(src) {
295
+ const tokens = [];
296
+ let atom = "";
297
+ const flush = () => {
298
+ if (atom.trim())
299
+ tokens.push(atom);
300
+ atom = "";
301
+ };
302
+ for (let i = 0; i < src.length;) {
303
+ const ch = src[i];
304
+ if (ch === "'" || ch === '"') {
305
+ const close = src.indexOf(ch, i + 1);
306
+ const stop = close === -1 ? src.length : close + 1;
307
+ atom += src.slice(i, stop);
308
+ i = stop;
309
+ }
310
+ else if (src.startsWith("&&", i) || src.startsWith("||", i)) {
311
+ flush();
312
+ tokens.push(src.slice(i, i + 2));
313
+ i += 2;
314
+ }
315
+ else if (ch === "(" || ch === ")") {
316
+ flush();
317
+ tokens.push(ch);
318
+ i += 1;
319
+ }
320
+ else {
321
+ atom += ch;
322
+ i += 1;
323
+ }
324
+ }
325
+ flush();
326
+ return tokens;
327
+ }
328
+ /** Whether the expression still has a path that could have been evaluated, given
329
+ * the variables the response actually returned a column for.
330
+ *
331
+ * The boolean structure decides, not the mere presence of a `||`:
332
+ * - a term is evaluable when every variable it names has a column;
333
+ * - `A && B` needs BOTH sides — a missing mandatory conjunct is an unprovable
334
+ * claim even if the other side is a disjunction;
335
+ * - `A || B` needs only one — a row can legitimately match through one operand
336
+ * while the other is not evaluable at all (probed 2026-08-03: `F1 > 0 || F2
337
+ * > 0` over 09992.HK, where `finc_pe_ttm` has no HK coverage, matches on F2).
338
+ *
339
+ * So `F1 && (F2 || F3)` missing F1, and `F1 || F2` missing both, are both
340
+ * unevaluable and must fail — checking only for a `||` anywhere would let them
341
+ * through. */
342
+ export function screenerExpressionIsEvaluable(expression, present) {
343
+ const tokens = tokenizeScreenerExpression(expression ?? "");
344
+ let pos = 0;
345
+ const unit = () => {
346
+ if (tokens[pos] === "(") {
347
+ pos += 1;
348
+ const value = or();
349
+ if (tokens[pos] === ")")
350
+ pos += 1;
351
+ return value;
352
+ }
353
+ const atom = tokens[pos++] ?? "";
354
+ // A term naming no variable (a literal comparison) is always evaluable.
355
+ return (atom.replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? []).every((ref) => present.has(ref));
356
+ };
357
+ const and = () => {
358
+ let value = unit();
359
+ // Evaluate both sides before combining: short-circuiting would leave the
360
+ // parser mid-expression.
361
+ while (tokens[pos] === "&&") {
362
+ pos += 1;
363
+ const right = unit();
364
+ value = value && right;
365
+ }
366
+ return value;
367
+ };
368
+ const or = () => {
369
+ let value = and();
370
+ while (tokens[pos] === "||") {
371
+ pos += 1;
372
+ const right = and();
373
+ value = value || right;
374
+ }
375
+ return value;
376
+ };
377
+ return or();
378
+ }
379
+ /** Variables the expression actually filters on, string literals stripped. These
380
+ * are the bindings whose VALUES the result depends on — a column missing for one
381
+ * of them means the filter cannot be shown to have been applied. */
382
+ export function screenerExpressionFields(expression) {
383
+ return (expression ?? "").replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? [];
384
+ }
286
385
  // Parse the screener's `--indicator "F1:code"` bindings and merge each one's
287
386
  // `--indicator-param "F1:key=value"` specs. Params key off the variable, not the
288
387
  // code, because the same indicator may legitimately appear under two variables
289
388
  // with different parameters (e.g. the same price on two dates).
290
- export function parseScreenerIndicators(bindings, paramSpecs) {
389
+ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
291
390
  const params = parseParamSpecs(paramSpecs, "--indicator-param", "F1:key=value");
292
391
  const indicators = new Map();
293
392
  for (const spec of bindings) {
@@ -312,5 +411,35 @@ export function parseScreenerIndicators(bindings, paramSpecs) {
312
411
  throw new ValidationError(`--indicator-param references "${field}", which no --indicator binds`);
313
412
  }
314
413
  }
414
+ // The server does reject this (100003), but only after a round trip — and the
415
+ // symmetric mistake (binding a variable the expression never uses) it accepts
416
+ // silently while still billing the extra column.
417
+ for (const ref of screenerExpressionFields(expression)) {
418
+ if (!indicators.has(ref)) {
419
+ throw new ValidationError(`--expression references "${ref}", which no --indicator binds`);
420
+ }
421
+ }
315
422
  return [...indicators.values()];
316
423
  }
424
+ /** Indicator codes bound to more than one screener variable, in first-seen
425
+ * order. The API is specified to allow this (one code under two variables with
426
+ * different parameters), but the server currently mis-resolves it — probed
427
+ * 2026-08-03: ALL such bindings are answered from the EARLIEST date among them,
428
+ * that one value lands in the first of their columns, and the rest come back
429
+ * null. The surviving number therefore need not belong to the variable it is
430
+ * labelled with, so it is not merely "one column is missing": the values shown
431
+ * and the set of matched securities are both untrustworthy. (The same request
432
+ * also returns an empty set roughly a third of the time.)
433
+ *
434
+ * Callers warn rather than reject: the capability is intended and a server-side
435
+ * fix is in flight, so a hard block here would have to be reverted. */
436
+ export function duplicateScreenerCodes(indicators) {
437
+ const seen = new Set();
438
+ const duplicated = new Set();
439
+ for (const { indicatorCode } of indicators) {
440
+ if (seen.has(indicatorCode))
441
+ duplicated.add(indicatorCode);
442
+ seen.add(indicatorCode);
443
+ }
444
+ return [...duplicated];
445
+ }
@@ -426,15 +426,17 @@ export class GangtiseClient {
426
426
  process.stderr.write(`[gangtise] retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
427
427
  },
428
428
  }).catch((error) => {
429
- // EDE uses 999999 for "no data for this query" (probed 2026-07-11) — the
430
- // generic "系统错误,请稍后重试" hint would send the user retrying a query
431
- // that will never have data. Swap in a fetch-specific hint. Only the data
429
+ // Until 2026-08-01 EDE answered a no-data query with 999999, so this hint
430
+ // led with "多为查询无数据". No-data now comes back as an empty array, which
431
+ // leaves 999999 meaning an actual server fault — but the parameter checklist
432
+ // is still the right first move, because a wrong param name or date axis
433
+ // silently yields an EMPTY TABLE rather than this error. Only the data
432
434
  // endpoints take a date/security/params; indicator.search shares the
433
435
  // no-999999 policy but has just a keyword, so it keeps the generic hint
434
436
  // instead of nonsensical date/scope/param guidance.
435
- const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series';
437
+ const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series' || endpoint.key === 'indicator.screener';
436
438
  if (isIndicatorFetch && error instanceof ApiError && error.code === '999999') {
437
- throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 的 999999 多为查询无数据——先核对:日期匹配指标周期(财务/MRQ 用报告期末如 2025-12-31、日频估值用交易日)、标的在 scopeList 覆盖内、parameterList 中 required 参数已补;确认应有数据再重试。');
439
+ throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名是静默失效)、日期匹配指标周期(财务报表类用报告期末如 2025-12-31,PE/PB 等日频估值用交易日)、标的在 scopeList 覆盖内、required 参数已补。注意「查询无数据」现在返回空表而不是此码。');
438
440
  }
439
441
  throw error;
440
442
  });
@@ -34,15 +34,21 @@ export function buildStockPoolStocksBody(options) {
34
34
  poolIdList: options.poolId?.length ? options.poolId : ["all"],
35
35
  };
36
36
  }
37
- /** Parameter keys that carry an indicator's own query date. The 2026-08-01 EDE
37
+ /** Parameter keys that REPLACE an indicator's `tradeDate`. The 2026-08-01 EDE
38
38
  * revision dropped the root-level `date`, so `--date` now fans out into each
39
- * indicator's `tradeDate`. Report-period indicators read `reportDate`/`sDate`
40
- * instead and answer a `tradeDate` with an EMPTY result rather than an error
41
- * (probed 2026-08-01 on `is_op_rev_mom`), so a caller-supplied date of any of
42
- * these kinds suppresses the injection for that indicator. Injecting into an
43
- * indicator that takes no date at all is harmless — `pty_op_scope` still
44
- * answers normally with a stray `tradeDate` attached. */
45
- const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate", "sDate"]);
39
+ * indicator's `tradeDate`. Report-period indicators read `reportDate` instead
40
+ * and answer a `tradeDate` with an EMPTY result rather than an error (probed
41
+ * 2026-08-01 on `is_op_rev_mom`), so a caller-supplied `reportDate` suppresses
42
+ * the injection for that indicator. Injecting into an indicator that takes no
43
+ * date at all is harmless — `pty_op_scope` still answers normally with a stray
44
+ * `tradeDate` attached.
45
+ *
46
+ * `sDate` is deliberately NOT here. It is an interval START, not a substitute:
47
+ * `qte_vol_intvl` declares `tradeDate` required (the interval END) and `sDate`
48
+ * optional. Treating it as a replacement dropped `--date` and silently moved the
49
+ * interval end — probed 2026-08-02, 茅台 sDate=2024-01-02 returned 2,265,873,849
50
+ * without a tradeDate vs 65,687,435 with tradeDate=2024-01-31, both exit 0. */
51
+ const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate"]);
46
52
  function withQueryDate(groups, codes, date) {
47
53
  const merged = new Map();
48
54
  for (const group of groups ?? [])
@@ -81,14 +87,22 @@ export function buildIndicatorTimeSeriesBody(options) {
81
87
  };
82
88
  }
83
89
  export function buildIndicatorScreenerBody(options) {
84
- const indicators = parseScreenerIndicators(options.indicator, options.indicatorParam);
90
+ const indicators = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
85
91
  return {
86
92
  universe: maybeArray(options.security),
87
93
  expression: options.expression,
88
- indicatorList: options.date
89
- ? indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
90
- ? indicator
91
- : { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] }))
92
- : indicators,
94
+ // Every indicator gets a date, including ones whose parameterList is empty.
95
+ // Harmless there (a parameterless indicator answers normally with a stray
96
+ // tradeDate), and it keeps one rule for the whole list rather than a
97
+ // per-indicator exception.
98
+ //
99
+ // It also used to be load-bearing: through 2026-08-02 the screener DROPPED
100
+ // any indicator sent with `parameters: []`, so the official doc's own
101
+ // `F3 contains '酒'` example could not work as written. Re-probed 2026-08-03:
102
+ // fixed server-side. Kept anyway — unconditional is simpler than conditional,
103
+ // and it survives a rollback.
104
+ indicatorList: indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
105
+ ? indicator
106
+ : { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] })),
93
107
  };
94
108
  }
@@ -83,7 +83,10 @@ const ERROR_HINTS = {
83
83
  "130004": "下载 ID 需为数字,检查该命令的 --*-id 参数是否传对。",
84
84
  "130005": "对照命令 --help 检查 --file-type / --content-type 取值。",
85
85
  "140001": "稍后用对应 *-check 命令查询。",
86
- "140002": "异步生成失败(终态)——换参数重新提交,重试同一 dataId 不会变。",
86
+ // 两类来源共用此码:AI 异步生成失败,以及 EDE 的参数/表达式错误(实测 2026-08-02:
87
+ // 枚举越界「参数 adjustType 的值 99 不在有效范围内 [1,2,3,4]」、表达式语法错误)。
88
+ // 两者都是终态、都不该重试,所以文案只说「换参数重来」而不预设是哪一类。
89
+ "140002": "终态失败,重试同一请求不会变——按 msg 改参数后重新提交;EDE 的指标参数名/枚举以 indicator search 的 parameterList 为准。",
87
90
  // ── 接口专有 2xxxxx ──
88
91
  "210001": "换一篇,或改用 list 取正文摘要。",
89
92
  "220001": "改用 list 取正文摘要。",
@@ -98,10 +101,11 @@ const ERROR_HINTS = {
98
101
  "900001": "对照命令 --help 检查必填项。",
99
102
  "900002": "请求方法不正确(服务端 msg 为「请求类型有误」)——`raw call` 时检查该 endpoint 是 GET 还是 POST。",
100
103
  "903301": "次日再试,或联系客户经理提额。",
101
- // EDE 专有旧码,未被 2026-07-17 重排收编但仍是 indicator 取数的主要报错
102
- // (references/commands/indicator.md 把这两个列为首要排查项)。
104
+ // EDE 专有旧码。2026-08-02 复测已不再出现——入参/表达式错走 100003,指标必填
105
+ // 参数缺失与枚举越界走 140002。保留兜底:错误码是按模块分批迁移的,回滚或未迁完
106
+ // 的路径仍可能吐旧码,届时给条能用的提示比 fallthrough 强。
103
107
  "410001": "补齐 --indicator / --security;`time-series` 不支持「多指标 × 多证券」,改用 `indicator cross-section`。",
104
- "410106": "读 `indicator search --format json` 的 parameterList,用 --indicator-param 补上 required:true 的参数(如 periodNum / startDate / fiscalYear)。",
108
+ "410106": "读 `indicator search --format json` 的 parameterList,用 --indicator-param 补上 required:true 的参数(如 periodNum / sDate / fiscalYear)。",
105
109
  "410004": "换证券或日期确认该条件下本应有数据;仍失败多为未开通该指标,联系客户经理。",
106
110
  "410110": "稍后用对应 *-check 命令查询。",
107
111
  "410111": "终态,换参数后重新提交,重试同一请求不会变。",
@@ -126,8 +130,10 @@ export class ApiError extends CliError {
126
130
  /** Server-specified Retry-After (ms), set on 429 responses so the transport
127
131
  * backoff can honor it instead of the default exponential schedule. */
128
132
  retryAfterMs,
129
- /** Context-specific hint that beats the generic per-code table — e.g. EDE's
130
- * 999999 means "no data", not the table's "系统错误,请稍后重试". */
133
+ /** Context-specific hint that beats the generic per-code table — e.g. the
134
+ * EDE fetch endpoints replace 999999's generic "系统错误,请稍后重试" with a
135
+ * parameter checklist, because a wrong param name or date axis there yields
136
+ * an empty TABLE rather than this code. */
131
137
  hintOverride) {
132
138
  super(message);
133
139
  this.code = code;