gangtise-openapi-cli 0.30.1 → 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,11 +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`)。
9
10
  - **v0.30.1 — 2026-08-02**:修复 `sDate` 吞掉查询日期导致的区间指标静默错数,让条件选股的文本筛选真正可用,并在服务端整行/整列丢数据时给出警告。
10
11
  - **v0.30.0 — 2026-08-02**:适配 EDE 接口重构(`universe` 取代 `securityCodeList`、截面矩阵转置、指标元数据结构化),新增 `indicator screener` 条件选股,并修正复权参数名。
11
12
  - **v0.29.0 — 2026-07-25**:新增财报日历与 PDF 解析工具,群消息补 `quoteMsg`,并加强大整数 ID 与高积分调用防护。
12
13
  - **v0.28.3 — 2026-07-24**:修复列式响应在错误 `--field` 下静默错列的问题,并校正 EDE 与字段文档。
13
- - **v0.28.2 — 2026-07-24**:EDE 批量取数新增 `--key-by name|code`,优化无数据诊断与相关 Skill 文档。
14
14
 
15
15
  ### 历史里程碑
16
16
 
@@ -297,6 +297,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
297
297
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
298
298
  - 安全上限:自动翻页最多 1000 页,防止异常循环
299
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` 硬失败
300
301
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
301
302
 
302
303
  ## 智能文件命名
@@ -581,7 +582,7 @@ gangtise indicator time-series --indicator qte_close \
581
582
  --start-date 2026-07-29 --end-date 2026-07-31 --format table
582
583
 
583
584
  # 条件选股:F1/F2… 绑定指标,用表达式组合筛选(--indicator-param 按变量索引,不是按 code)
584
- # --date 必填:screener 会丢弃 parameters 为空的指标,CLI 靠它给每个指标挂上日期
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" \
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, duplicateScreenerCodes, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseScreenerIndicators, 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 { droppedFromMatrix, 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,21 +697,64 @@ 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
- /** Tell the caller what the server left out. EDE does not pad missing data with
700
+ /** Mark and report what the server left out. EDE does not pad missing data with
701
701
  * `null`: an indicator empty for every security vanishes from `indicatorList`,
702
702
  * a security empty for every indicator vanishes from `securityCodeList` (probed
703
703
  * 2026-08-02). Without this the shortfall is invisible — exit code 0, a
704
704
  * plausible-looking table, and a `--key-by code` mapping whose key simply is not
705
- * there. stderr only, so piped output stays machine-readable. */
706
- function warnDropped(data, requestedSecurities, requestedIndicators) {
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
+ }
707
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
+ }
708
729
  const parts = [];
709
730
  if (indicators.length > 0)
710
731
  parts.push(`indicators ${indicators.join(", ")}`);
711
732
  if (securities.length > 0)
712
733
  parts.push(`securities ${securities.join(", ")}`);
713
- if (parts.length > 0) {
714
- 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.\n`);
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)`);
715
758
  }
716
759
  }
717
760
  const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series, screener");
@@ -723,35 +766,83 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
723
766
  });
724
767
  await printData(unwrapIndicatorData(raw), format, options.output);
725
768
  }));
726
- 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) => {
727
770
  const format = parseOutputFormat(options.format);
771
+ requireIndicatorScope(options.indicator, options.security);
728
772
  const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
729
- const data = unwrapIndicatorData(raw);
730
- warnDropped(data, options.security, options.indicator);
731
- await printData(flattenCrossSection(data, 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);
732
779
  }));
733
- 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) => {
734
781
  const format = parseOutputFormat(options.format);
782
+ requireIndicatorScope(options.indicator, options.security);
735
783
  const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
736
- const data = unwrapIndicatorData(raw);
737
- warnDropped(data, options.security, options.indicator);
738
- // Pass the REQUESTED security count: the server drops securities with no data,
739
- // and deriving the axis from the response turns a single-indicator ×
740
- // two-security query into an unlabelled indicator column when one is uncovered.
741
- await printData(flattenTimeSeries(data, options.keyBy, options.security.length), 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);
742
793
  }));
743
- 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").requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as every indicator's tradeDate unless it already has one — required because the screener drops any indicator sent with no parameters at all", 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) => {
744
795
  const format = parseOutputFormat(options.format);
745
- const duplicated = duplicateScreenerCodes(parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression));
796
+ requireIndicatorScope(options.indicator, options.security);
797
+ const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
798
+ const duplicated = duplicateScreenerCodes(bindings);
746
799
  if (duplicated.length > 0) {
747
- process.stderr.write(`[gangtise] warning: ${duplicated.join(", ")} bound to more than one variable — the API allows this but the server currently answers such a request with an empty result (probed 2026-08-02). Split into separate calls until it is fixed.\n`);
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`);
748
801
  }
749
802
  const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
750
803
  // Same payload shape as cross-section (one row per matched security, one
751
804
  // column per indicator) with a `field` on each indicator entry. No dropped-row
752
- // warning here: a security missing from a screener result means it failed the
805
+ // flag here: a security missing from a screener result means it failed the
753
806
  // filter, which is the whole point.
754
- await printData(flattenCrossSection(unwrapIndicatorData(raw), options.keyBy), format, options.output);
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);
755
846
  }));
756
847
  program.addCommand(indicator);
757
848
  const tool = new Command("tool").description("Research tool APIs: PDF parsing");
@@ -832,12 +923,64 @@ async function checkForUpdate(timeoutMs = 2000) {
832
923
  }
833
924
  catch { /* best-effort: offline or a slow registry must not break --version */ }
834
925
  }
835
- // `gangtise ... | head` closes stdout early; without a handler the final big write
836
- // 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"]);
837
980
  process.stdout.on("error", (error) => {
838
- if (error?.code === "EPIPE")
981
+ if (error?.code && READER_GONE.has(error.code))
839
982
  process.exit(0);
840
- throw error;
983
+ reportFatal(error);
841
984
  });
842
985
  async function main() {
843
986
  // Positional check, not argv.includes: "--version" appearing later (e.g. as
@@ -288,6 +288,100 @@ const SCREENER_FIELD = /^F[1-9][0-9]*$/;
288
288
  * `contains 'F2 系列'` operand is not mistaken for a reference. */
289
289
  const SCREENER_FIELD_REF = /\bF[1-9][0-9]*\b/g;
290
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
+ }
291
385
  // Parse the screener's `--indicator "F1:code"` bindings and merge each one's
292
386
  // `--indicator-param "F1:key=value"` specs. Params key off the variable, not the
293
387
  // code, because the same indicator may legitimately appear under two variables
@@ -320,7 +414,7 @@ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
320
414
  // The server does reject this (100003), but only after a round trip — and the
321
415
  // symmetric mistake (binding a variable the expression never uses) it accepts
322
416
  // silently while still billing the extra column.
323
- for (const ref of (expression ?? "").replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? []) {
417
+ for (const ref of screenerExpressionFields(expression)) {
324
418
  if (!indicators.has(ref)) {
325
419
  throw new ValidationError(`--expression references "${ref}", which no --indicator binds`);
326
420
  }
@@ -329,10 +423,16 @@ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
329
423
  }
330
424
  /** Indicator codes bound to more than one screener variable, in first-seen
331
425
  * order. The API is specified to allow this (one code under two variables with
332
- * different parameters), but the server currently answers such a request with an
333
- * empty result set — probed 2026-08-02. Callers warn rather than reject: the
334
- * capability is intended and a server-side fix is in flight, so a hard block
335
- * here would have to be reverted. */
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. */
336
436
  export function duplicateScreenerCodes(indicators) {
337
437
  const seen = new Set();
338
438
  const duplicated = new Set();
@@ -436,7 +436,7 @@ export class GangtiseClient {
436
436
  // instead of nonsensical date/scope/param guidance.
437
437
  const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series' || endpoint.key === 'indicator.screener';
438
438
  if (isIndicatorFetch && error instanceof ApiError && error.code === '999999') {
439
- throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名是静默失效)、日期匹配指标周期(财务/MRQ 用报告期末如 2025-12-31、日频估值用交易日)、标的在 scopeList 覆盖内、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 参数已补。注意「查询无数据」现在返回空表而不是此码。');
440
440
  }
441
441
  throw error;
442
442
  });
@@ -92,11 +92,15 @@ export function buildIndicatorScreenerBody(options) {
92
92
  universe: maybeArray(options.security),
93
93
  expression: options.expression,
94
94
  // Every indicator gets a date, including ones whose parameterList is empty.
95
- // That is not just convenience: the screener drops any indicator sent with
96
- // `parameters: []` — probed 2026-08-02, `pty_op_scope` + `F1 contains '酒'`
97
- // matches nothing with an empty list and correctly returns 五粮液/贵州茅台
98
- // with a (for that indicator meaningless) tradeDate attached. The official
99
- // doc's own `F3 contains '酒'` example cannot work as written.
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.
100
104
  indicatorList: indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
101
105
  ? indicator
102
106
  : { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] })),
@@ -130,8 +130,10 @@ export class ApiError extends CliError {
130
130
  /** Server-specified Retry-After (ms), set on 429 responses so the transport
131
131
  * backoff can honor it instead of the default exponential schedule. */
132
132
  retryAfterMs,
133
- /** Context-specific hint that beats the generic per-code table — e.g. EDE's
134
- * 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. */
135
137
  hintOverride) {
136
138
  super(message);
137
139
  this.code = code;