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 +3 -2
- package/dist/src/cli.js +171 -28
- package/dist/src/core/args.js +105 -5
- package/dist/src/core/client.js +1 -1
- package/dist/src/core/commandBodies.js +9 -5
- package/dist/src/core/errors.js +4 -2
- package/dist/src/core/indicatorMatrix.js +297 -42
- package/dist/src/core/printer.js +9 -3
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +10 -6
- package/gangtise-openapi/references/commands/indicator.md +39 -20
- package/gangtise-openapi/references/commands/quote.md +1 -1
- package/gangtise-openapi/references/examples.md +10 -12
- package/gangtise-openapi/references/response-schema.md +3 -1
- package/package.json +1 -1
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
|
|
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
|
-
/**
|
|
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.
|
|
706
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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 =
|
|
730
|
-
|
|
731
|
-
|
|
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 =
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
// and
|
|
740
|
-
//
|
|
741
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
805
|
+
// flag here: a security missing from a screener result means it failed the
|
|
753
806
|
// filter, which is the whole point.
|
|
754
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
|
981
|
+
if (error?.code && READER_GONE.has(error.code))
|
|
839
982
|
process.exit(0);
|
|
840
|
-
|
|
983
|
+
reportFatal(error);
|
|
841
984
|
});
|
|
842
985
|
async function main() {
|
|
843
986
|
// Positional check, not argv.includes: "--version" appearing later (e.g. as
|
package/dist/src/core/args.js
CHANGED
|
@@ -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
|
|
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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();
|
package/dist/src/core/client.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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 }] })),
|
package/dist/src/core/errors.js
CHANGED
|
@@ -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.
|
|
134
|
-
*
|
|
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;
|