gangtise-openapi-cli 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,12 +6,11 @@
6
6
 
7
7
  README 仅列最近 5 个版本摘要:
8
8
 
9
+ - **v0.30.1 — 2026-08-02**:修复 `sDate` 吞掉查询日期导致的区间指标静默错数,让条件选股的文本筛选真正可用,并在服务端整行/整列丢数据时给出警告。
9
10
  - **v0.30.0 — 2026-08-02**:适配 EDE 接口重构(`universe` 取代 `securityCodeList`、截面矩阵转置、指标元数据结构化),新增 `indicator screener` 条件选股,并修正复权参数名。
10
11
  - **v0.29.0 — 2026-07-25**:新增财报日历与 PDF 解析工具,群消息补 `quoteMsg`,并加强大整数 ID 与高积分调用防护。
11
12
  - **v0.28.3 — 2026-07-24**:修复列式响应在错误 `--field` 下静默错列的问题,并校正 EDE 与字段文档。
12
13
  - **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
 
@@ -576,12 +575,13 @@ gangtise indicator cross-section \
576
575
  --date 2026-07-31 --format table
577
576
  # 输出列:security / name / <各指标名>…(v0.30.0 起无 date 列——日期挂在每个指标的参数上)
578
577
 
579
- # 时间序列:多指标 × 单证券 或 单指标 × 多证券(不能多 × 多,否则报 410001)
578
+ # 时间序列:多指标 × 单证券 或 单指标 × 多证券(不能多 × 多,否则报 100003)
580
579
  gangtise indicator time-series --indicator qte_close \
581
580
  --security 600519.SH --security 09992.HK \
582
581
  --start-date 2026-07-29 --end-date 2026-07-31 --format table
583
582
 
584
583
  # 条件选股:F1/F2… 绑定指标,用表达式组合筛选(--indicator-param 按变量索引,不是按 code)
584
+ # --date 必填:screener 会丢弃 parameters 为空的指标,CLI 靠它给每个指标挂上日期
585
585
  gangtise indicator screener \
586
586
  --indicator F1:qte_mkt_cptl --indicator F2:finc_pe_ttm \
587
587
  --indicator-param "F1:scale=8" \
@@ -589,16 +589,25 @@ gangtise indicator screener \
589
589
  --expression "F1 >= 500 && F2 <= 30" \
590
590
  --date 2026-07-31 --format table
591
591
 
592
+ # 文本筛选:经营范围含「酒」(contains/notcontains 只对 string 类型指标有效)
593
+ gangtise indicator screener --indicator F1:pty_op_scope \
594
+ --security 1000000287 --expression "F1 contains '酒'" \
595
+ --date 2026-07-31 --format table
596
+
592
597
  # 复权 / 指标专属参数用 --indicator-param "code:key=value"
593
598
  # ⚠️ 参数名必须以 search 的 parameterList 为准:传错名服务端静默忽略、退回默认值,不报错
594
599
  gangtise indicator cross-section --indicator qte_close --security 600519.SH \
595
600
  --date 2024-01-02 --indicator-param "qte_close:adjustType=3" # 1不复权/2前复权/3后复权/4定点
596
601
  # 不复权 1685.01 → 前复权 1531.225 → 后复权 13609.6168(前复权在最新交易日等于不复权,验证要用历史日)
597
602
 
598
- # 必填参数:很多指标默认调用报 410106(缺必填参数),按 parameterList 的 required 补齐再取:
599
- # N 期统计补 periodNum、区间/周期类(如 qte_amp_mo 月振幅)补 startDate、年度/分红类补 fiscalYear
603
+ # 必填参数:部分指标缺必填参数会报 140002,按 parameterList 的 required 补齐再取:
604
+ # N 期统计补 periodNum、区间类补 sDate(起始日,tradeDate 仍是终点)、年度/分红类补 fiscalYear
600
605
  gangtise indicator cross-section --indicator finc_roe_avg_avg --security 600519.SH \
601
606
  --date 2026-03-31 --indicator-param "finc_roe_avg_avg:periodNum=4"
607
+
608
+ # 区间指标:sDate 是起点、--date 下发的 tradeDate 是终点,两者共存
609
+ gangtise indicator cross-section --indicator qte_vol_intvl --security 600519.SH \
610
+ --date 2024-01-31 --indicator-param "qte_vol_intvl:sDate=2024-01-02"
602
611
  ```
603
612
 
604
613
  ### Alternative(行业指标数据库 EDB)
@@ -696,6 +705,7 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
696
705
  | `999012` / `999013` / `999014` | 账号禁用 / 已过期 / 租户失效 |
697
706
  | `999016` | 调用方 IP 不在允许范围 |
698
707
  | `999999` | Gangtise 系统错误,请稍后重试(`indicator` 端点无数据已改为返回空表,此码基本只剩真故障) |
708
+ | `140002` | 终态失败:AI 异步生成失败,或 `indicator` 的参数/表达式错误(枚举越界、语法错)——改参数重提,不重试 |
699
709
  | `100003` | 参数值非法——**最宽的兜底码**;msg 通常已指明字段(如「limit 最小为 1,最大为 10000」),先读 msg |
700
710
  | `100001` | 缺必填参数(msg 带字段名,如「缺少必填参数: reportId」) |
701
711
  | `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, 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 { droppedFromMatrix, flattenCrossSection, flattenTimeSeries, 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";
@@ -697,7 +697,24 @@ 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
+ /** Tell the caller 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. stderr only, so piped output stays machine-readable. */
706
+ function warnDropped(data, requestedSecurities, requestedIndicators) {
707
+ const { securities, indicators } = droppedFromMatrix(data, requestedSecurities, requestedIndicators);
708
+ const parts = [];
709
+ if (indicators.length > 0)
710
+ parts.push(`indicators ${indicators.join(", ")}`);
711
+ if (securities.length > 0)
712
+ 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`);
715
+ }
716
+ }
717
+ const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series, screener");
701
718
  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
719
  const format = parseOutputFormat(options.format);
703
720
  const raw = await client.call("indicator.search", {
@@ -709,18 +726,31 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
709
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) => {
710
727
  const format = parseOutputFormat(options.format);
711
728
  const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
712
- await printData(flattenCrossSection(unwrapIndicatorData(raw), options.keyBy), format, options.output);
729
+ const data = unwrapIndicatorData(raw);
730
+ warnDropped(data, options.security, options.indicator);
731
+ await printData(flattenCrossSection(data, options.keyBy), format, options.output);
713
732
  }));
714
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) => {
715
734
  const format = parseOutputFormat(options.format);
716
735
  const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
717
- await printData(flattenTimeSeries(unwrapIndicatorData(raw), options.keyBy), format, options.output);
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);
718
742
  }));
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) => {
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) => {
720
744
  const format = parseOutputFormat(options.format);
745
+ const duplicated = duplicateScreenerCodes(parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression));
746
+ 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`);
748
+ }
721
749
  const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
722
750
  // Same payload shape as cross-section (one row per matched security, one
723
- // column per indicator) with a `field` on each indicator entry.
751
+ // 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
753
+ // filter, which is the whole point.
724
754
  await printData(flattenCrossSection(unwrapIndicatorData(raw), options.keyBy), format, options.output);
725
755
  }));
726
756
  program.addCommand(indicator);
@@ -283,11 +283,16 @@ 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;
286
291
  // Parse the screener's `--indicator "F1:code"` bindings and merge each one's
287
292
  // `--indicator-param "F1:key=value"` specs. Params key off the variable, not the
288
293
  // code, because the same indicator may legitimately appear under two variables
289
294
  // with different parameters (e.g. the same price on two dates).
290
- export function parseScreenerIndicators(bindings, paramSpecs) {
295
+ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
291
296
  const params = parseParamSpecs(paramSpecs, "--indicator-param", "F1:key=value");
292
297
  const indicators = new Map();
293
298
  for (const spec of bindings) {
@@ -312,5 +317,29 @@ export function parseScreenerIndicators(bindings, paramSpecs) {
312
317
  throw new ValidationError(`--indicator-param references "${field}", which no --indicator binds`);
313
318
  }
314
319
  }
320
+ // The server does reject this (100003), but only after a round trip — and the
321
+ // symmetric mistake (binding a variable the expression never uses) it accepts
322
+ // silently while still billing the extra column.
323
+ for (const ref of (expression ?? "").replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? []) {
324
+ if (!indicators.has(ref)) {
325
+ throw new ValidationError(`--expression references "${ref}", which no --indicator binds`);
326
+ }
327
+ }
315
328
  return [...indicators.values()];
316
329
  }
330
+ /** Indicator codes bound to more than one screener variable, in first-seen
331
+ * 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. */
336
+ export function duplicateScreenerCodes(indicators) {
337
+ const seen = new Set();
338
+ const duplicated = new Set();
339
+ for (const { indicatorCode } of indicators) {
340
+ if (seen.has(indicatorCode))
341
+ duplicated.add(indicatorCode);
342
+ seen.add(indicatorCode);
343
+ }
344
+ return [...duplicated];
345
+ }
@@ -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 为准(传错名是静默失效)、日期匹配指标周期(财务/MRQ 用报告期末如 2025-12-31、日频估值用交易日)、标的在 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,18 @@ 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
+ // 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.
100
+ indicatorList: indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
101
+ ? indicator
102
+ : { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] })),
93
103
  };
94
104
  }
@@ -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": "终态,换参数后重新提交,重试同一请求不会变。",
@@ -76,9 +76,53 @@ function indicatorHeaders(indicators, keyBy, reserved) {
76
76
  const codes = indicators.map((meta) => optionalString(meta.code));
77
77
  const fields = indicators.map((meta, i) => optionalString(meta.field) ?? codes[i]);
78
78
  const bases = keyBy === "code" ? codes : indicators.map((meta) => optionalString(meta.name));
79
- return buildHeaders(bases, fields, reserved);
79
+ // On a screener payload every column carries its variable, so a repeated base
80
+ // can suffix ALL of its occurrences instead of leaving the first one bare.
81
+ // A bare `收盘价` next to `收盘价 (F2)` reads as "the" close price when it is
82
+ // really just whichever variable the server happened to list first.
83
+ const screener = indicators.some((meta) => meta.field !== undefined);
84
+ if (!screener)
85
+ return buildHeaders(bases, fields, reserved);
86
+ const repeated = new Set(bases.filter((base, i) => base !== undefined && bases.indexOf(base) !== i));
87
+ return buildHeaders(bases.map((base, i) => (base !== undefined && repeated.has(base) ? `${base} (${fields[i] ?? i})` : base)), fields, reserved);
80
88
  }
81
89
  const CROSS_SECTION_COLUMNS = ["security", "name"];
90
+ /** What the caller asked for that the response does not contain. The server
91
+ * does NOT pad missing data with `null`: an indicator that is empty for every
92
+ * security disappears from `indicatorList`, and a security that is empty for
93
+ * every indicator disappears from `securityCodeList` (probed 2026-08-02 — three
94
+ * indicators over 09992.HK came back with one, two securities over
95
+ * `qte_mkt_cptl` came back with one). Only partial gaps become `null`.
96
+ *
97
+ * That is the dangerous shape: `--key-by code` batch mapping finds no key at
98
+ * all rather than a null, and a cross-market pull quietly returns fewer rows
99
+ * than requested with exit code 0.
100
+ *
101
+ * Universe entries with no `.` are skipped — those are sector IDs, which the
102
+ * server expands into constituents, so their absence from the response is
103
+ * expected rather than a dropped row. */
104
+ export function droppedFromMatrix(data, requestedSecurities, requestedIndicators) {
105
+ const empty = { securities: [], indicators: [] };
106
+ if (!data || typeof data !== "object")
107
+ return empty;
108
+ const d = data;
109
+ const returnedSecurities = new Set(asStringArray(d.securityCodeList) ?? []);
110
+ const returnedIndicators = new Set((asIndicatorMetaList(d.indicatorList) ?? []).map((meta) => optionalString(meta.code)));
111
+ return {
112
+ securities: requestedSecurities.filter((code) => code.includes(".") && !returnedSecurities.has(code)),
113
+ indicators: requestedIndicators.filter((code) => !returnedIndicators.has(code)),
114
+ };
115
+ }
116
+ /** The matrix and its axis labels must agree, or every cell is off by a row.
117
+ * The 2026-08-01 revision transposed cross-section without a version marker, so
118
+ * a future re-transpose has to fail loudly instead of silently relabelling
119
+ * columns. Rows are compared, not cells: the server legitimately returns short
120
+ * rows when a trailing indicator has no data anywhere. */
121
+ function assertRowCount(rows, expected, axis) {
122
+ if (rows.length !== expected) {
123
+ throw new ApiError(`Indicator matrix shape mismatch: got ${rows.length} value rows for ${expected} ${axis} — the response layout may have changed`, undefined);
124
+ }
125
+ }
82
126
  // Cross-section (and the screener, whose payload is the same shape plus a
83
127
  // per-indicator `field`): one row per security, one column per indicator.
84
128
  // `values` is [security][indicator], so security i's value for indicator j is
@@ -92,6 +136,7 @@ export function flattenCrossSection(data, keyBy = "name") {
92
136
  const indicators = asIndicatorMetaList(d.indicatorList);
93
137
  if (!Array.isArray(d.values) || !securityCode || !indicators)
94
138
  return data;
139
+ assertRowCount(d.values, securityCode.length, "securities");
95
140
  const securityName = asStringArray(d.securityNameList);
96
141
  const headers = indicatorHeaders(indicators, keyBy, CROSS_SECTION_COLUMNS);
97
142
  const list = securityCode.map((code, i) => {
@@ -107,7 +152,14 @@ export function flattenCrossSection(data, keyBy = "name") {
107
152
  // Time-series: one row per date. Columns are the indicators (single-security
108
153
  // case) or the securities (single-indicator case) — exactly one dimension
109
154
  // varies, per the API contract. `values` stays a 2D [series][date] matrix.
110
- export function flattenTimeSeries(data, keyBy = "name") {
155
+ //
156
+ // `requestedSecurities` decides the axis. Deriving it from the RESPONSE is
157
+ // wrong: the server drops securities that have no data at all, so a
158
+ // single-indicator × two-security query where one security is uncovered comes
159
+ // back with one security and silently relabels the column as the indicator —
160
+ // the caller then cannot tell whose series they are holding (probed 2026-08-02:
161
+ // `finc_pe_ttm` over 600519.SH + 09992.HK renders a bare 市盈率(TTM) column).
162
+ export function flattenTimeSeries(data, keyBy = "name", requestedSecurities) {
111
163
  if (!data || typeof data !== "object")
112
164
  return data;
113
165
  const d = data;
@@ -117,7 +169,8 @@ export function flattenTimeSeries(data, keyBy = "name") {
117
169
  if (!Array.isArray(d.values) || !dates || !securityCode || !indicators)
118
170
  return data;
119
171
  const securityName = asStringArray(d.securityNameList);
120
- const seriesAreIndicators = securityCode.length <= 1;
172
+ const seriesAreIndicators = (requestedSecurities ?? securityCode.length) <= 1;
173
+ assertRowCount(d.values, seriesAreIndicators ? indicators.length : securityCode.length, seriesAreIndicators ? "indicators" : "securities");
121
174
  // Map over securityCode rather than securityNameList directly: a response that
122
175
  // omits the names must still yield one header per security (falling back to
123
176
  // the code), not zero columns.
@@ -104,11 +104,12 @@ const RETRYABLE_API_CODES = new Set(["999999"]);
104
104
  // - 140002 PROCESSING_FAILED: the async *-check endpoints (get-content) declare no
105
105
  // retry policy, so a 140002@500 would be retried 2× by the default policy BEFORE
106
106
  // asyncContent's FAILED_CODES gets to call it terminal — that guard sits above
107
- // client.call's withRetry and cannot see the retries. 140002 means "generation
108
- // failed" (terminal by definition) and only those async endpoints can return it,
109
- // so a blanket rule is safe and skips the wasted retries. Server still emits the
110
- // legacy 410111 today (410111@400 isn't retryable anyway); this is a forward guard
111
- // for the documented 140002@500.
107
+ // client.call's withRetry and cannot see the retries. 140002 is terminal in both
108
+ // of its meanings — async "generation failed", and (probed 2026-08-02) the EDE
109
+ // indicator endpoints' parameter/expression errors, e.g. an out-of-range enum or
110
+ // a malformed screener expression — so the blanket rule stays correct and skips
111
+ // the wasted retries. Server still emits the legacy 410111 today (410111@400
112
+ // isn't retryable anyway); this is a forward guard for the documented 140002@500.
112
113
  const TERMINAL_API_CODES = new Set(["999011", "140002"]);
113
114
  // Connect-phase / DNS failures: the request provably never reached the server, so a
114
115
  // replay cannot double-execute (or double-bill) anything even under "no-replay".
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.30.0";
2
+ export const CLI_VERSION = "0.30.1";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.30.0"
3
+ version: "0.30.1"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
@@ -172,8 +172,8 @@ description: |-
172
172
  - EDE 取数前必须用 `search --format json` 同时核对:`indicatorName` + `description` 语义准确、`scopeList` 覆盖全部目标市场 / 证券类型、`parameterList` 必填参数与枚举可满足;`scopeList` 缺失 / `null` / 空或任一项不符,都视为无法证明覆盖并回退专用接口。专用接口也不覆盖目标市场时,说明当前不可用,不要硬调。`scopeList` 按指标各不相同,不能因 EDE 服务支持 A / 港 / 美股就假定某个指标三市场都覆盖
173
173
  - `indicator search` 免费,`cross-section` / `time-series` 按单元格计费;除多证券批量的效率收益外,仍优先免费 / 低价的 `quote` 或 `fundamental`
174
174
  - 行业 / 宏观指标(空调销量、社融等,无证券维度)走 `alternative edb-*`(EDB),不要与证券级 EDE 混用
175
- - EDE 单元格级缺值返回 `null` 且保留证券行;**整个查询无数据返回空表**(2026-08-01 起不再报 `999999`)——所以**参数写错也表现为空表**,拿到空表先查参数名和日期语义,别当成真没数据。日期语义按指标分三类:财务报表指标=报告期末(可为非交易日)、`finc_pe_ttm` 等日频估值=最新交易日、`finc_pb_mrq`(MRQ) 等=最近报告期末(交易日取 `null`);混合取数按各自有效日期分次 `cross-section` 再按证券合并,别塞进同一个 `--date`。详见 `references/commands/indicator.md`
176
- - **EDE 指标参数名一律以 `indicator search --format json` 的 `parameterList` 为准**,不要凭记忆或照抄示例:服务端会改参数名(`adjustmentType` → `adjustType`,2026-08-01),**传错名是静默忽略、退回默认值**而不是报错——实测 `adjustmentType=3` 返回的是不复权价,看着正常实则错数据。`--indicator` / `--security` 支持板块 ID(`reference sector-search` 的 10 位 `sectorId`)。⚠️ 根级 `--scale` 会污染不声明 `scale` 的指标(`qte_close` 被 `--scale 8` 缩成 `0`),价格与金额混查时改用 `--indicator-param "code:scale=8"`
175
+ - EDE 缺数据分三档,**别只认 `null`**:部分缺 → 单元格 `null`(行列都在);某指标对所有证券都无数据 → **整列消失**;某证券对所有指标都无数据 → **整行消失**;整个查询无数据 → **空表**(2026-08-01 起不再报 `999999`)。后三种退出码都是 0,`--key-by code` 回填时 key 会直接不存在。CLI 已在 stderr 提示被整个略过的指标/证券。**参数写错也表现为空表**,拿到空表先查参数名和日期语义,别当成真没数据。日期语义按指标分三类:财务报表指标=报告期末(可为非交易日)、`finc_pe_ttm` 等日频估值=最新交易日、`finc_pb_mrq`(MRQ) 等=最近报告期末(交易日取 `null`);混合取数按各自有效日期分次 `cross-section` 再按证券合并,别塞进同一个 `--date`。详见 `references/commands/indicator.md`
176
+ - **EDE 指标参数名一律以 `indicator search --format json` 的 `parameterList` 为准**,不要凭记忆或照抄示例:服务端会改参数名(`adjustmentType` → `adjustType`,2026-08-01),**传错名是静默忽略、退回默认值**而不是报错——实测 `adjustmentType=3` 返回的是不复权价,看着正常实则错数据。`--security` 支持板块 ID(`reference sector-search` 的 10 位 `sectorId`;`--indicator` 只收指标编码)。⚠️ 根级 `--scale` 会污染不声明 `scale` 的指标(`qte_close` 被 `--scale 8` 缩成 `0`),价格与金额混查时改用 `--indicator-param "code:scale=8"`
177
177
  - "业绩点评"双义消歧:**检索已有**(研报/纪要里的业绩点评内容)走 `insight ... list --llm-tag earningsReview`(0.1/条);**AI 现生成**一份走 `ai earnings-review`(异步、50/次)。不确定问一句
178
178
 
179
179
  ## 公司名 → 证券代码
@@ -280,7 +280,7 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
280
280
  | ✅ `0000001008` | Token 服务端失效(他处登录挤掉)——**旧码未切,token 自愈依赖它** | **强制重新登录并重试一次** | 无 AK/SK 时无法自愈,提示重新登录 |
281
281
  | ✅ `0000001007` | 请求未携带 Bearer token | — | 检查 `GANGTISE_TOKEN` / AK/SK 是否已 export |
282
282
  | ✅ `900002` | **请求方法不正确**(msg「请求类型有误」,HTTP 405)——旧文档写作"缺少 uid"是错的 | — | `raw call` 时确认该 endpoint 是 GET 还是 POST |
283
- | `410106` / 缺参 | `indicator` 缺必填参数(msg 直接指明,如「必填参数 periodNum 不能为空」;HTTP 500 故 CLI 重试 ×2) | **自动重试 ×2** | 读 `indicator search --format json` 的 `parameterList` 补 `required:true` 参数 |
283
+ | ✅ `140002` | 终态参数错:AI 异步生成失败,或 `indicator` 的指标必填参数缺失 / 枚举越界 / 表达式语法错(实测 2026-08-02) | **不重试**(终态码) | 按 msg 改参数重提;EDE 的参数名与枚举读 `indicator search --format json` 的 `parameterList` |
284
284
 
285
285
  **⚠️ 实测发现的坑(都是"不报错"型,最难发现)**
286
286
  - 🔴 **日期只写 `YYYY-MM-DD`、时间只写 `YYYY-MM-DD HH:mm:ss`(或 10/13 位时间戳);CLI v0.28.0 起 date 与 datetime 两类、含所有 insight/vault 透传参数都本地拦截**。服务端对「年在后」格式**日月顺序随分隔符翻转**且静默误解析(HTTP 200、不回显实际用的日期):`07/01/2026`(斜杠)读成 **2026-01-07**、`07-01-2026`(横杠)读成 **2026-07-01**,差半年。实测 `insight research list --start-time`:`07/01/2026` 命中 1562 条、`07-01-2026` 命中 210 条(分别 = 标准 `2026-01-07` / `2026-07-01`);`quote day-kline`/`kline-hk`/`kline-us`/`index`、`fundamental balance-sheet` 同理。v0.28.0 前透传命令(research/summary/announcement-hk/us/vault/minute-kline 等)**静默放行**,且同值在本地转时间戳的 `announcement`(A 股)与透传的 hk/us 之间还会差半年、都 exit 0。现在全部在发请求前报 `ValidationError`,**但绕过 CLI 直连接口务必自己保证格式**
@@ -30,7 +30,7 @@ gangtise indicator search --keyword <text> [--limit <n>]
30
30
  - 返回字段:`indicatorCode` / `indicatorName` / `description`(算法与口径)/ `scopeList`(该指标适用的市场 + 证券类型 + `usageRestriction`)/ `parameterList`(可传的 `--indicator-param` 参数名及枚举)/ `score`
31
31
  - **市场范围按指标判断**:`scopeList` 会返回实际覆盖范围,且指标之间不同;不能笼统写成每个指标都覆盖 A / 港 / 美股。实测 `finc_pe_ttm` / `finc_pb_mrq` 仅 A 股,`is_op_rev_ttm` / `is_shnp_ttm` 覆盖 A 股 + 港股(2026-08-01 新增港股,实测有数)。目标列表含任一 scope 外证券时,本批 EDE 校验不通过,应回退专用接口
32
32
  - **`scopeList[].usageRestriction`**:该指标在具体接口上的限制,`null` = 无限制。如「不支持指标时间序列接口」意味着只能用 `cross-section` / `screener`
33
- - ⚠️ **`scopeList` 是声明不是保证**:`qte_mkt_cptl`/`shr_tot` 声称覆盖港股,实测港股仍返 `null`(2026-08-01)。批量前抽查一行
33
+ - ⚠️ **`scopeList` 是声明不是保证**:`qte_mkt_cptl`/`shr_tot` 声称覆盖港股+美股,实测港股与美股均**无数据**(2026-08-02 复测)。而且无数据是**整列消失**不是返 `null`,批量前抽查一行——CLI 会在 stderr 提示哪些指标/证券被整个略过
34
34
  - 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE),**不是 `.US`**——实测 `AAPL.US` 查不到数据,须用 `AAPL.O`(官方示例里的 `AAPL.US` 是笔误)
35
35
 
36
36
  ```bash
@@ -48,7 +48,8 @@ gangtise indicator cross-section --indicator <code> [--indicator <code2>] \
48
48
  - `--indicator`(**至少 1 个**):指标编码,来自 `search`,可重复传多个
49
49
  - `--security`(**至少 1 个**):证券代码,如 `600519.SH`(A股)/ `09992.HK`(港股)/ `AAPL.O`(美股,用 `.O`/`.N` 后缀,非 `.US`),可重复传多个。**也接受板块 ID**(`reference sector-search` 返回的 10 位 `sectorId`,如 `1000000287` 中信白酒 → 19 只成分股),代码与板块可混传,服务端取并集去重。⚠️ 中信行业码那类 9 位 ID(`100800109`)**不是** `sectorId`,传进去返 0 只
50
50
  - `--date`(**必选**):数据日期 `yyyy-MM-dd`。**CLI 把它下发为每个指标各自的 `tradeDate`**(2026-08-01 起服务端取消了根级 date)。日期语义按指标分三类——财务报表指标=报告期末(可为非交易日,实测 `2024-03-31` 可取数)、`finc_pe_ttm` 等日频估值=交易日、`finc_pb_mrq`(MRQ) 等=最近报告期末(交易日取 `null`,详见下方「日期路由」)
51
- - ⚠️ **吃 `reportDate` / `sDate` 的指标必须显式传**:`--indicator-param "code:reportDate=2024-12-31"`。这类指标收到 `tradeDate` 会**静默返回空结果**(不报错,实测 `is_op_rev_mom`)。CLI 检测到你已为某指标传了 `tradeDate`/`reportDate`/`sDate` 就不再注入 `--date`
51
+ - ⚠️ **吃 `reportDate` 的指标必须显式传**:`--indicator-param "code:reportDate=2024-12-31"`。这类指标收到 `tradeDate` 会**静默返回空结果**(不报错,实测 `is_op_rev_mom`)。CLI 检测到你已为某指标传了 `tradeDate` 或 `reportDate` 就不再注入 `--date`
52
+ - `sDate`(区间起始日)**不算**替代日期:它和 `tradeDate` 共存(`tradeDate` 是区间终点且 required),传了 `sDate` 后 `--date` 照常下发
52
53
  - `--currency`:币种 `DFT`(原始,默认)/`CNY`/`HKD`/`USD`/`EUR`/`GBP`/`JPY`/`TWD`/`MOP`/`AUD`(**大写**,2026-08-01 起服务端枚举已统一大写)
53
54
  - `--scale`:量纲 `0`(个,默认)/`3`(千)/`4`(万)/`6`(百万)/`8`(亿)/`9`(十亿)
54
55
  - ⚠️ **根级 `--scale` 会污染不支持 scale 的指标**:`qte_close` 的 `parameterList` 里没有 `scale`,但根级 `--scale 8` 会把收盘价 1350.6 缩成 `0`(实测 2026-08-01,与文档「根级参数仅对支持的指标生效」不符)。价格类和金额类指标混查时,别用根级 `--scale`,改用 `--indicator-param "code:scale=8"` 只作用于该指标
@@ -74,13 +75,13 @@ gangtise indicator time-series --indicator <code> [--indicator <code2>] \
74
75
  [--calendar-type <ND|TD|WD>] [--currency <c>] [--scale <s>] [--indicator-param <spec>] [--key-by name|code]
75
76
  ```
76
77
 
77
- - `--indicator` / `--security`:同上,但**只允许「多指标 × 单证券」或「单指标 × 多证券」**,不能两边都多个(要多 × 多用 `cross-section`,否则报 `410001`)。**单指标时 `--security` 才能传板块 ID**;多指标时只能传一个证券代码、不得传板块 ID
78
+ - `--indicator` / `--security`:同上,但**只允许「多指标 × 单证券」或「单指标 × 多证券」**,不能两边都多个(要多 × 多用 `cross-section`,否则报 `100003`「仅支持多指标单证券或单指标多证券」)。**单指标时 `--security` 才能传板块 ID**;多指标时只能传一个证券代码、不得传板块 ID
78
79
  - `--start-date` / `--end-date`(**均必选**):区间端点 `yyyy-MM-dd`。时序的时间范围由这两个参数统管,**不要**再用 `--indicator-param` 传 `tradeDate` 这类单日期参数
79
80
  - `--calendar-type`:日期类型 `ND`(自然日)/`TD`(交易日,默认)/`WD`(工作日)。`TD` 且跨市场时,`date` 列是各市场交易日的**并集**
80
81
  - `--currency` / `--scale`:同 `cross-section`(含根级 `--scale` 的污染坑)
81
82
  - **输出(宽表)**:每行一个日期,列为 `date / <各序列名>…`;序列在「单证券」时是各**指标**,在「多证券」时是各**证券**
82
83
  - **`--key-by name|code`**(默认 `name`):同 `cross-section`;`code` 模式下单证券列=各 `indicatorCode`、多证券列=各 `securityCode`,批量按 code 回填用它
83
- - ⚠️ 部分指标**不支持时序接口**:`search` 返回的 `scopeList[].usageRestriction` 会写明(如「不支持指标时间序列接口」),`null` 表示无限制。取数前顺手看一眼,省得白花积分
84
+ - ⚠️ 部分指标标注**不支持时序接口**:`search` 返回的 `scopeList[].usageRestriction` 会写明(如「不支持指标时间序列接口」),`null` 表示无限制。**但它不是硬约束**——实测 `qte_vol_intvl` 带着该标注调时序照样返回数据,服务端不拦。把它当"口径可能不对、结果别当真"的提示,而不是"会报错"的保证
84
85
 
85
86
  ```bash
86
87
  # 单个已实现估值指标 × 多证券:列 = 证券
@@ -95,7 +96,7 @@ gangtise indicator time-series --indicator finc_pe_ttm \
95
96
  ```bash
96
97
  gangtise indicator screener --indicator <F1:code> [--indicator <F2:code2>] \
97
98
  --security <code|sectorId> [--security <code2>] --expression <expr> \
98
- [--date <yyyy-MM-dd>] [--indicator-param <F1:key=value>] [--key-by name|code]
99
+ --date <yyyy-MM-dd> [--indicator-param <F1:key=value>] [--key-by name|code]
99
100
  ```
100
101
 
101
102
  按指标条件从一个证券范围里筛出符合条件的股票,一次调用完成「取数 + 过滤」,比 `cross-section` 拉全量再本地筛更省事。
@@ -106,11 +107,26 @@ gangtise indicator screener --indicator <F1:code> [--indicator <F2:code2>] \
106
107
  - 比较:`==` `>` `<` `>=` `<=` `!=`
107
108
  - 文本:`contains` / `notcontains`(不区分大小写,**仅对 `dataType: string` 的指标有效**)
108
109
  - 逻辑:`&&` `||`,分组 `(` `)`
109
- - `--date`:同 `cross-section`,下发为每个指标的 `tradeDate`(已自带日期参数的指标不覆盖)
110
- - `--indicator-param`:格式是 **`F1:key=value`(按变量,不是按 code)**——同一个指标 code 允许绑到两个变量取不同参数(如比较两个日期的收盘价),只有变量能区分。引用了没绑定的变量会直接报错,不会静默丢弃
110
+ - `--date`(**必选**):下发为**每个**指标的 `tradeDate`(已带 `tradeDate`/`reportDate` 的不覆盖)。之所以必填且无条件下发:**screener 会丢弃任何 `parameters` 为空数组的指标**——`pty_op_scope` 这种无参指标按官方格式传 `[]` 时永远 0 命中,随便挂个(对它无意义的)`tradeDate` 就正常返回。CLI 已自动处理,你只要记得传 `--date`
111
+ - `--indicator-param`:格式是 **`F1:key=value`(按变量,不是按 code)**。引用了没绑定的变量会直接报错,不会静默丢弃
112
+ - `--expression` 里引用未绑定的变量,CLI **本地就拦**(不发请求、不计费);服务端也会报 `100003`
111
113
  - **输出(宽表)**:同 `cross-section`,每行一只**命中**的证券,列为 `security / name / <各指标名>…`;无命中返回空表
112
114
  - **积分**:与 `cross-section` 同价(按单元格计),但计费基数是**筛选前**的范围 × 指标数,别拿全市场板块随手试
113
115
 
116
+ ### 已知服务端缺陷(2026-08-02 实测,已报后台)
117
+
118
+ | 现象 | 影响 | 现在怎么办 |
119
+ | :--- | :--- | :--- |
120
+ | **同一 `indicatorCode` 绑到两个变量** → 返回空结果 | 「同一指标比两个日期」这类用法暂时不可用 | CLI 会在 stderr 警告。拆成两次 `cross-section` 再本地比 |
121
+ | **`contains` / `notcontains` 需要指标带参数才生效** | 官方文档的招牌示例 `F3 contains '酒'`(`parameters: []`)0 命中 | CLI 已通过必填 `--date` 自动绕过,正常可用 |
122
+
123
+ ```bash
124
+ # 文本筛选:白酒板块里经营范围含「酒」的公司
125
+ gangtise indicator screener --indicator F1:pty_op_scope \
126
+ --security 1000000287 --expression "F1 contains '酒'" \
127
+ --date 2026-07-31 --format table # 19 只全部命中
128
+ ```
129
+
114
130
  ```bash
115
131
  # 中信白酒板块里,市值≥500亿 且 PE(TTM)≤30
116
132
  gangtise indicator screener \
@@ -142,24 +158,35 @@ gangtise indicator cross-section --indicator qte_close --security 600519.SH \
142
158
 
143
159
  ## 必填参数与错误码(取数前必读)
144
160
 
145
- 截面/时序**单元格级缺值返回 `null`**(证券行保留、不丢行)。**整个查询无数据现在返回空数组**(`securityCodeList`/`values` 皆为 `[]`,CLI 显示 `Total: 0`),不再像 2026-07 那样报 `999999` + HTTP 500 —— 这也意味着**参数写错的失败会以「空表」而不是报错的形式出现**,拿到空表先怀疑日期语义 / 参数名,别当成「确实没数据」。取数报错主要是这几个码:
161
+ **缺数据的三种形态(2026-08-02 实测,务必分清)**:
162
+
163
+ | 缺的范围 | 服务端怎么返 | 后果 |
164
+ | :--- | :--- | :--- |
165
+ | **部分**缺(某证券的某指标) | 该单元格 `null`,行列都在 | 安全,一眼可见 |
166
+ | 某指标对 universe 内**所有**证券都无数据 | 该指标**整列**从 `indicatorList` 消失 | 🔴 `--key-by code` 回填时 key 根本不存在 |
167
+ | 某证券对**所有**指标都无数据 | 该证券**整行**从 `securityCodeList` 消失 | 🔴 跨市场批量静默少行,退出码仍 0 |
168
+ | 整个查询无数据 | `securityCodeList`/`values` 皆 `[]`(`Total: 0`),不再报 `999999` | 🔴 **参数写错也长这样** |
169
+
170
+ 实测:3 个指标查港股 `09992.HK` 只回 1 个指标(市值/股本整列消失);`qte_mkt_cptl` 查 `600519.SH`+`09992.HK` 只回 1 行。**CLI 会在 stderr 打印被整个略过的指标 / 证券**(`--format json` 下 stdout 仍是干净 JSON),看到警告就去核对 `scopeList` 和日期语义。
171
+
172
+ 取数报错主要是这几个码:
146
173
 
147
174
  | 错误码 | 实际含义 | 怎么办 |
148
175
  | :--- | :--- | :--- |
149
176
  | `100001` | **缺少必填参数**:如 `universe` 没传 | 补齐 `--indicator`/`--security` |
150
- | `410001` | 入参错误:`time-series` 传了「多指标 × 多证券」 | 多 × 多改用 `cross-section` |
151
- | 缺参报错(曾为 `410106`) | **缺指标级必填参数**:服务端会直接指明缺哪个,如「指标 X 的必填参数 periodNum(期数) 不能为空」(仍以 HTTP 500 返回,CLI 重试 2 次后透出该消息) | 读 `search --format json` 的 `parameterList`,把 `required:true` 的参数用 `--indicator-param` 补上 |
177
+ | `100003`@400 | 入参/表达式错误:`time-series` 传了「多指标 × 多证券」、`expression` 引用未声明变量、`indicatorParamList` 的 code 不在 `indicatorCodeList` 里 | 按 msg 改;多 × 多改用 `cross-section`。CLI 已在本地拦截「表达式引用未绑定变量」,不会白发一次请求 |
178
+ | `140002`@500 | **终态参数错**:指标必填参数缺失、枚举越界(如「参数 adjustType 的值 99 不在有效范围内 [1,2,3,4]」)、表达式语法错误 | **不重试**(CLI 已把 140002 列为终态码)。读 `search --format json` 的 `parameterList` 改参数名/取值 |
152
179
  | `999999` | 系统故障。2026-08-01 起「无数据」已改为返回空数组,所以这个码基本只剩真故障(2026-07-26 曾出现 EDE 取数端全线 999999,08-01 已恢复) | CLI 对 indicator 端点**不重试此码**(v0.27.0);确认参数无误仍报错就是服务端问题 |
153
180
  | `130001`(旧 `410004`) | 数据未找到,或**该指标无权限**(内层信封失败会带具体 msg,如"指标无权限";此码被服务端复用) | 检查查询条件与指标权限;换证券/日期仍失败多为无权限,联系管理员开通 |
154
181
 
155
- ### 必填参数(`410106` 的根因)
182
+ ### 必填参数(`140002` 的根因)
156
183
 
157
- 相当一部分指标默认调用就报 `410106`,因为有必填参数没传。**先完成语义 + `scopeList` + `parameterList` 三项校验;其中凡 `required:true` 的参数都用 `--indicator-param "指标code:参数=值"` 补上。** 三类高频必填参数:
184
+ 相当一部分指标缺必填参数时会报 `140002`。**先完成语义 + `scopeList` + `parameterList` 三项校验;其中凡 `required:true` 的参数都用 `--indicator-param "指标code:参数=值"` 补上。** 三类高频必填参数:
158
185
 
159
186
  | 参数 | 适用指标 | 示例 |
160
187
  | :--- | :--- | :--- |
161
188
  | `periodNum` | N 期统计(N 期均值/最值,如 `finc_roe_avg_avg` 平均ROE N期均值) | `--indicator-param "finc_roe_avg_avg:periodNum=4"`;部分还需配**年报日期**才出数(实测 `finc_roe_avg_avg`@`2026-03-31` 空、@`2025-12-31` 有) |
162
- | `startDate` | 区间/周期类,整数 `YYYYMMDD`(含全部 `qte` 周期变体,如 `qte_amp_mo` 月振幅、换手率) | `--indicator-param "qte_amp_mo:startDate=20260401"`;取值须匹配该指标**周期**(月/季/周/年窗口不同,`20260101` 未必命中,空则按变体周期调 `startDate`) |
189
+ | `sDate` | 区间类的**起始日**(如 `qte_vol_intvl` 区间成交量、`qte_avg_vol` 区间日均成交量),格式 `yyyy-MM-dd` | `--indicator-param "qte_vol_intvl:sDate=2024-01-02"`。⚠️ **`sDate` 不能替代 `tradeDate`**——它是区间起点,`tradeDate`(=区间终点)仍是 required,`--date` 会照常下发。历史文档写的 `startDate=YYYYMMDD` 已不存在,传它是静默失效(茅台实测:错名 296 万 vs 正确 4673 万)。另:`qte_amp_mo`(月振幅)等周期变体现在只吃 `tradeDate`,没有起始日参数 |
163
190
  | `fiscalYear` | 年度/报告期类(如 `div_cash_yr` 年度现金分红) | `--indicator-param "div_cash_yr:fiscalYear=2025"` |
164
191
 
165
192
  > `paramValue` 一律按**字符串**约定传(`periodNum=4` 内部即 `"4"`,CLI 已处理)。
@@ -175,9 +202,9 @@ gangtise indicator cross-section --indicator qte_close --security 600519.SH \
175
202
  - 现金流量表附注/间接法科目(多数 `cf_`)→ **只在年报/半年报披露**,季报日期取不到,改用年报日期 `2025-12-31`
176
203
  - 行情类(`qte_` 等)→ 用**交易日**,但常规行情仍应改走 `quote`
177
204
  - **混合日期语义要拆查询**:同时要“某报告期营收 / EPS”和“估值 PE / PB”时,按各自有效日期分别调用 `cross-section` 再按 `security` 合并(财务=报告期末、PE=最新交易日、PB(MRQ)=最近报告期末);不要把不同日期语义的指标塞进同一个 `--date`
178
- - **探索性取数**:单元格级缺值返回 `null` 且保留证券行,时序局部无值可为空行;**整个查询无数据返回空表**(不再报 `999999`)。看趋势用 `time-series` + 覆盖报告期的区间,但不能把缺值当成通过语义 / scope 校验。
205
+ - **探索性取数**:只有**部分**缺值才返回 `null` 且保留行列;整指标 / 整证券无数据是**整列 / 整行消失**,整个查询无数据是**空表**(不再报 `999999`)。看趋势用 `time-series` + 覆盖报告期的区间,但不能把缺值当成通过语义 / scope 校验。
179
206
  - **名称反查 code 要核对,别取首条**:存在同显示名的兄弟指标——单季 `cf_finc_exp_qtr` 与累计 `cf_finc_exp` 都叫「财务费用」,`bs_fmt`/`cf_fmt`/`is_fmt` 都叫「报表格式」。`search` 按名称模糊匹配,目标 code 高概率在 top1 但不绝对,要看 `indicatorCode` 确认。
180
- - **批量查询做失败拆分**:某指标**缺必填参数**或入参错误时会整批报错(单元格级无数据按 `null` 返回),逐指标单查能定位是哪个指标缺参/不可查。
207
+ - **批量查询做失败拆分**:某指标**缺必填参数**或入参错误时会整批报 `140002`,逐指标单查能定位是哪个指标缺参/不可查。留意 stderr 的「整列/整行被略过」警告——那不是报错,但同样是这批数据不完整的信号。
181
208
  - **市值量纲(复测 2026-08-01)**:`qte_mkt_cptl`(总市值)**实测仅 A 股有数**——`scopeList` 已声称覆盖港/美股,但港股实测仍返 `null`(`shr_tot` 总股本同样);**默认返原始「元」**(茅台 ≈ `1.7e12`,即 1.7 万亿),别误当天文数字。用 `scale` 数字码缩放(`0`元 / `3`千 / `4`万 / `6`百万 / `8`亿 / `9`十亿——`scale=8` → `16883` 亿元)、`currency` 换币种(**大写** `DFT`本币 / `CNY` / `HKD` / `USD` …)。**跨证券比市值前先统一 `scale`+`currency`**。
182
209
  - **币种与汇率(复测 2026-08-01,已修复)**:`DFT`(原始币种)识别正确——A股=CNY、港股行情=HKD、美股=USD;汇率换算自洽(互逆且三角一致,误差 <0.003%)。⚠️ 但**同一只港股,行情类的原始币种是 HKD、财务类可能是 CNY**(如泡泡玛特财报以人民币计),跨市场比财务数据时显式传 `--currency CNY` 别依赖 `DFT`。另:财务类指标的汇率按**报告期**折算、行情类按查询日折算,两者隐含汇率会有细微差异,属正常口径差别。
183
210
  - **EDE 财务指标的 `reportType`(2026-08-01 裁决完毕,悬案关闭)**:服务端 enum label 已改正,现在 **label 与实际取数一致**,直接按 label 传即可:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {