gangtise-openapi-cli 0.30.1 → 0.32.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/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, 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";
@@ -87,10 +87,10 @@ async function runDownload(client, endpointKey, query, options) {
87
87
  */
88
88
  function addDownloadCommand(parent, spec) {
89
89
  const cmd = parent.command(spec.name ?? "download").requiredOption(`${spec.idOption} <id>`);
90
- if (spec.fileType?.required)
91
- cmd.requiredOption("--file-type <number>", spec.fileType.description);
92
- else if (spec.fileType)
93
- cmd.option("--file-type <number>", spec.fileType.description, spec.fileType.default);
90
+ if (spec.fileType) {
91
+ const option = new Option("--file-type <number>", spec.fileType.description).choices(spec.fileType.choices);
92
+ cmd.addOption(spec.fileType.required ? option.makeOptionMandatory() : option.default(spec.fileType.default));
93
+ }
94
94
  if (spec.contentTypeDescription)
95
95
  cmd.requiredOption("--content-type <type>", spec.contentTypeDescription);
96
96
  cmd.option("--output <path>").action((options) => withClient(async (client) => {
@@ -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
@@ -158,6 +158,7 @@ program.addCommand(lookup);
158
158
  const insight = new Command("insight").description("Insight APIs");
159
159
  const opinion = new Command("opinion");
160
160
  const summary = new Command("summary");
161
+ const pamirsSummary = new Command("pamirs-summary");
161
162
  const roadshow = new Command("roadshow");
162
163
  const siteVisit = new Command("site-visit");
163
164
  const strategy = new Command("strategy");
@@ -173,19 +174,39 @@ const independentOpinion = new Command("independent-opinion");
173
174
  const officialAccount = new Command("official-account");
174
175
  const qa = new Command("qa");
175
176
  const reportImage = new Command("report-image");
176
- addTimeFilters(opinion.command("list").option("--rank-type <number>", "Rank type", "1").option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
177
+ addTimeFilters(opinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
177
178
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
178
179
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
179
180
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker), industryList: maybeArray(options.industry), conceptList: maybeArray(options.concept),
180
181
  llmTagList: maybeArray(options.llmTag), sourceList: maybeArray(options.source),
181
182
  })));
182
- addTimeFilters(summary.command("list").option("--search-type <number>", "Search type", "1").option("--rank-type <number>", "Rank type", "1").option("--source <number>", "Source type", collectNumberList, []).option("--research-area <id>", "Research area", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.summary.list", {
183
+ addTimeFilters(summary.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--source <number>", "Source type", collectNumberList, []).option("--research-area <id>", "Research area", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.summary.list", {
183
184
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
184
185
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, sourceList: options.source.length ? options.source : undefined,
185
186
  researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution),
186
187
  categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
187
188
  }), { endpointKey: "insight.summary.list", idField: "summaryId" }));
188
- addDownloadCommand(summary, { endpointKey: "insight.summary.download", idOption: "--summary-id", idField: "summaryId", fallbackPrefix: "summary", fileType: { description: "File type: 1=original(default) 2=HTML; only affects meeting platform summaries" }, titleListEndpoint: "insight.summary.list" });
189
+ addDownloadCommand(summary, { endpointKey: "insight.summary.download", idOption: "--summary-id", idField: "summaryId", fallbackPrefix: "summary", fileType: { description: "File type: 1=original(default) 2=HTML; only affects meeting platform summaries", choices: ["1", "2"] }, titleListEndpoint: "insight.summary.list" });
190
+ // Pamirs is one lead institution's expert-summary library, exposed on its own
191
+ // path rather than as a `summary list` filter. It advertises a NARROWER filter
192
+ // set than `summary` — no --source / --institution / --participant-role — so the
193
+ // options are spelled out here instead of sharing summary's builder: an
194
+ // unsupported flag would be dropped server-side and silently widen the result.
195
+ // Every enum is whitelisted locally. The server drops an unrecognised VALUE the
196
+ // same way it drops an unrecognised FIELD — silently, returning the unfiltered
197
+ // set with exit 0. Worse, a bad `--search-type` takes `--keyword` down with it:
198
+ // `--keyword 茅台 --search-type 99` answers 2963 (the whole library) instead of 2,
199
+ // so the caller reads a full-library dump as a keyword hit. Same class of defect
200
+ // that put whitelists on securities-search / institution-search / official-account.
201
+ const PAMIRS_CATEGORIES = ["companyAnalysis", "industryAnalysis"];
202
+ const PAMIRS_MARKETS = ["aShares", "hkStocks", "usChinaConcept", "usStocks"];
203
+ addTimeFilters(pamirsSummary.command("list").description("List Pamirs expert summaries (requires the expert-summary database)").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID; unlike most insight lists this accepts BOTH citicIndustry (1008001xx) and swIndustry (104xx0000) codes", collectList, []).option("--security <code>", "Security code, e.g. 000001.SZ", collectList, []).option("--category <name>", `Category: ${PAMIRS_CATEGORIES.join(" / ")}`, collectList, []).option("--market <name>", `Market: ${PAMIRS_MARKETS.join(" / ")}`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.pamirs-summary.list", {
204
+ from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
205
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword,
206
+ researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security),
207
+ categoryList: parseChoiceList(options.category, "--category", PAMIRS_CATEGORIES), marketList: parseChoiceList(options.market, "--market", PAMIRS_MARKETS),
208
+ }), { endpointKey: "insight.pamirs-summary.list", idField: "summaryId" }));
209
+ addDownloadCommand(pamirsSummary, { endpointKey: "insight.pamirs-summary.download", idOption: "--summary-id", idField: "summaryId", fallbackPrefix: "pamirs-summary", fileType: { description: "File type: 1=original(default) 2=HTML", choices: ["1", "2"] }, titleListEndpoint: "insight.pamirs-summary.list" });
189
210
  const addScheduleList = (command, endpointKey, fields) => {
190
211
  const list = command.command("list");
191
212
  if (fields.researchArea)
@@ -310,7 +331,7 @@ performanceCalendar.command("list").description("Earnings calendar (业绩预告
310
331
  }, { endpointKey: "insight.performance-calendar.list", idField: "performanceReportId" });
311
332
  });
312
333
  addDownloadCommand(performanceCalendar, { endpointKey: "insight.performance-calendar.download", idOption: "--performance-report-id", idField: "performanceReportId", fallbackPrefix: "performance-calendar", titleListEndpoint: "insight.performance-calendar.list" });
313
- addTimeFilters(research.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--broker <id>", "Broker ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--source <type>", "Source type", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.research.list", {
334
+ addTimeFilters(research.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--broker <id>", "Broker ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--source <type>", "Source type", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.research.list", {
314
335
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
315
336
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
316
337
  brokerList: maybeArray(options.broker), securityList: maybeArray(options.security), industryList: maybeArray(options.industry),
@@ -318,8 +339,8 @@ addTimeFilters(research.command("list").option("--search-type <number>", "Search
318
339
  ratingChangeList: maybeArray(options.ratingChange), minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }),
319
340
  maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }), sourceList: maybeArray(options.source),
320
341
  }), { endpointKey: "insight.research.list", idField: "reportId" }));
321
- addDownloadCommand(research, { endpointKey: "insight.research.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "research", fileType: { description: "File type: 1=PDF 2=Markdown", default: "1" }, titleListEndpoint: "insight.research.list" });
322
- addTimeFilters(foreignReport.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--region <id>", "Region ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-report.list", {
342
+ addDownloadCommand(research, { endpointKey: "insight.research.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "research", fileType: { description: "File type: 1=PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.research.list" });
343
+ addTimeFilters(foreignReport.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code", collectList, []).option("--region <id>", "Region ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-report.list", {
323
344
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
324
345
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
325
346
  securityList: maybeArray(options.security), regionList: maybeArray(options.region), categoryList: maybeArray(options.category),
@@ -327,21 +348,21 @@ addTimeFilters(foreignReport.command("list").option("--search-type <number>", "S
327
348
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
328
349
  minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
329
350
  }), { endpointKey: "insight.foreign-report.list", idField: "reportId" }));
330
- addDownloadCommand(foreignReport, { endpointKey: "insight.foreign-report.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "foreign-report", fileType: { description: "File type: 1=PDF 2=Markdown 3=CN-PDF 4=CN-Markdown", default: "1" }, titleListEndpoint: "insight.foreign-report.list" });
351
+ addDownloadCommand(foreignReport, { endpointKey: "insight.foreign-report.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "foreign-report", fileType: { description: "File type: 1=PDF 2=Markdown 3=CN-PDF 4=CN-Markdown", choices: ["1", "2", "3", "4"], default: "1" }, titleListEndpoint: "insight.foreign-report.list" });
331
352
  // Contract: A-share announcement startTime/endTime go out as 13-digit epoch millis
332
353
  // (parseTimestamp13), while HK/US announcement and every other insight list send the
333
354
  // datetime string straight through. All three filter correctly — verified live against
334
355
  // a narrow past window (each returns in-window rows). A-share's API also accepts the
335
356
  // string form, but the 13-digit conversion is kept as the historical spec contract;
336
357
  // don't "unify" it away without re-confirming the A-share announcement spec.
337
- addTimeFilters(announcement.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement.list", {
358
+ addTimeFilters(announcement.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement.list", {
338
359
  from: parseFrom(options.from), size: parseSize(options.size),
339
360
  startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
340
361
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword,
341
362
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
342
363
  }), { endpointKey: "insight.announcement.list", idField: "announcementId" }));
343
- addDownloadCommand(announcement, { endpointKey: "insight.announcement.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement", fileType: { description: "File type: 1=PDF 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement.list" });
344
- addTimeFilters(announcementHk.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. 01913.HK)", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement-hk.list", {
364
+ addDownloadCommand(announcement, { endpointKey: "insight.announcement.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement", fileType: { description: "File type: 1=PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement.list" });
365
+ addTimeFilters(announcementHk.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. 01913.HK)", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement-hk.list", {
345
366
  from: parseFrom(options.from), size: parseSize(options.size),
346
367
  startTime: options.startTime, endTime: options.endTime,
347
368
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
@@ -349,8 +370,8 @@ addTimeFilters(announcementHk.command("list").option("--search-type <number>", "
349
370
  keyword: options.keyword,
350
371
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
351
372
  }), { endpointKey: "insight.announcement-hk.list", idField: "announcementId" }));
352
- addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", fileType: { description: "File type: 1=original 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement-hk.list" });
353
- addTimeFilters(announcementUs.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. TSLA.O)", collectList, []).option("--category <id>", "Category ID (constant-list usShareAnnouncementCategory)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement-us.list", {
373
+ addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", fileType: { description: "File type: 1=original 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement-hk.list" });
374
+ addTimeFilters(announcementUs.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. TSLA.O)", collectList, []).option("--category <id>", "Category ID (constant-list usShareAnnouncementCategory)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement-us.list", {
354
375
  from: parseFrom(options.from), size: parseSize(options.size),
355
376
  startTime: options.startTime, endTime: options.endTime,
356
377
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
@@ -358,8 +379,8 @@ addTimeFilters(announcementUs.command("list").option("--search-type <number>", "
358
379
  keyword: options.keyword,
359
380
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
360
381
  }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
361
- addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
362
- addTimeFilters(foreignOpinion.command("list").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
382
+ addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
383
+ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
363
384
  from: parseFrom(options.from), size: parseSize(options.size),
364
385
  startTime: options.startTime, endTime: options.endTime,
365
386
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -368,7 +389,7 @@ addTimeFilters(foreignOpinion.command("list").option("--rank-type <number>", "Ra
368
389
  securityList: maybeArray(options.security), brokerList: maybeArray(options.broker),
369
390
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
370
391
  })));
371
- addTimeFilters(independentOpinion.command("list").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. GSK.N)", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.independent-opinion.list", {
392
+ addTimeFilters(independentOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. GSK.N)", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.independent-opinion.list", {
372
393
  from: parseFrom(options.from), size: parseSize(options.size),
373
394
  startTime: options.startTime, endTime: options.endTime,
374
395
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -376,8 +397,8 @@ addTimeFilters(independentOpinion.command("list").option("--rank-type <number>",
376
397
  industryList: maybeArray(options.industry), securityList: maybeArray(options.security),
377
398
  ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
378
399
  })));
379
- addDownloadCommand(independentOpinion, { endpointKey: "insight.independent-opinion.download", idOption: "--independent-opinion-id", idField: "independentOpinionId", fallbackPrefix: "independent-opinion", fileType: { description: "File type: 1=original HTML 2=CN-translated HTML", required: true } });
380
- addTimeFilters(officialAccount.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--account-id <id>", "Official account ID", collectList, []).option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--category <type>", "Article type: news/law/report/view/data/event/meeting/notice/recruit/investEdu/brand/notes/other", collectList, []).option("--industry <id>", "Industry ID (constant-list citicIndustry/swIndustry)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.official-account.list", {
400
+ addDownloadCommand(independentOpinion, { endpointKey: "insight.independent-opinion.download", idOption: "--independent-opinion-id", idField: "independentOpinionId", fallbackPrefix: "independent-opinion", fileType: { description: "File type: 1=original HTML 2=CN-translated HTML", choices: ["1", "2"], required: true } });
401
+ addTimeFilters(officialAccount.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--account-id <id>", "Official account ID", collectList, []).option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--category <type>", "Article type: news/law/report/view/data/event/meeting/notice/recruit/investEdu/brand/notes/other", collectList, []).option("--industry <id>", "Industry ID (constant-list citicIndustry/swIndustry)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.official-account.list", {
381
402
  from: parseFrom(options.from), size: parseSize(options.size),
382
403
  startTime: options.startTime, endTime: options.endTime,
383
404
  searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
@@ -386,7 +407,7 @@ addTimeFilters(officialAccount.command("list").option("--search-type <number>",
386
407
  accountIdList: maybeArray(options.accountId), securityList: maybeArray(options.security),
387
408
  categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
388
409
  }), { endpointKey: "insight.official-account.list", idField: "articleId" }));
389
- addDownloadCommand(officialAccount, { endpointKey: "insight.official-account.download", idOption: "--article-id", idField: "articleId", fallbackPrefix: "official-account", fileType: { description: "File type: 1=txt(default) 2=HTML", default: "1" }, titleListEndpoint: "insight.official-account.list" });
410
+ addDownloadCommand(officialAccount, { endpointKey: "insight.official-account.download", idOption: "--article-id", idField: "articleId", fallbackPrefix: "official-account", fileType: { description: "File type: 1=txt(default) 2=HTML", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.official-account.list" });
390
411
  // QA request keys are BARE (source/questionCategory/answerImportant), not the *List
391
412
  // convention — the body below mirrors the spec exactly. Datetimes pass through as strings.
392
413
  qa.command("list").requiredOption("--security-code <code>", "Security code, e.g. 601012.SH").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all (max page 500)").option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).option("--source <type>", "Source: conference/interactive/survey (repeat)", collectList, []).option("--question-category <name>", "Question category (repeat): productAndBusiness/capacityAndProjects/ordersAndCustomers/financialData/materialEvents/capitalOperations/shareholdersAndDividends/corporateGovernance/marketAndValuation/macroAndIndustry/risksAndOthers", collectList, []).option("--answer-important <flag>", "Answer involves key info: 1=yes 0=no (repeat; omit for all)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.qa.list", {
@@ -402,6 +423,7 @@ reportImage.command("list").requiredOption("--keyword <text>", "Search keyword,
402
423
  addDownloadCommand(reportImage, { endpointKey: "insight.report-image.download", idOption: "--chunk-id", idField: "chunkId", fallbackPrefix: "report-image" });
403
424
  insight.addCommand(opinion);
404
425
  insight.addCommand(summary);
426
+ insight.addCommand(pamirsSummary);
405
427
  insight.addCommand(roadshow);
406
428
  insight.addCommand(siteVisit);
407
429
  insight.addCommand(strategy);
@@ -697,21 +719,60 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
697
719
  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
720
  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
721
  program.addCommand(alternative);
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) {
722
+ /** Mark and report the request codes the server did not answer for at all.
723
+ *
724
+ * What this catches changed on the server. EDE used to drop any axis it had no
725
+ * DATA for — an indicator empty for every security vanished from
726
+ * `indicatorList`, a security empty for every indicator vanished from
727
+ * `securityCodeList`. Re-probed 2026-08-08: a coverage gap is now padded with
728
+ * `null` and keeps its row and column (`mgn_bal` × 00700.HK, `finc_pb_mrq` ×
729
+ * 09992.HK — both null, both present, even as the only cell in the request).
730
+ *
731
+ * A code the server cannot RESOLVE still vanishes: an unknown indicator code, or
732
+ * a security code with the wrong suffix (`AAPL.US`, whose real form is
733
+ * `AAPL.O`). So this is now a typo detector rather than a coverage detector —
734
+ * which is the more useful of the two, since a coverage gap is visible as `null`
735
+ * but a misspelled code is otherwise invisible: exit code 0, a plausible-looking
736
+ * table, and a `--key-by code` mapping whose key simply is not there.
737
+ *
738
+ * Same signal as a failed page or a row cap: `partial` on the payload (printData
739
+ * → exit 3) plus the omitted codes, so an automated caller can react without
740
+ * parsing stderr. */
741
+ function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
742
+ // A wholly empty response is not a partial one: the diff against the request
743
+ // would list everything as "omitted", which says nothing about which axis is
744
+ // at fault. Exit 0, but say why it is ambiguous.
745
+ if (isEmptyMatrix(data)) {
746
+ process.stderr.write("[gangtise] note: the query returned no data at all. Since a real coverage gap now comes back as a null cell rather than an empty table, this usually means NOTHING in the request resolved — every security code or every indicator code was unrecognised — or a parameter name is wrong. Cross-check codes against 'gangtise indicator search --format json' and 'gangtise reference securities-search'.\n");
747
+ return;
748
+ }
707
749
  const { securities, indicators } = droppedFromMatrix(data, requestedSecurities, requestedIndicators);
750
+ if (securities.length === 0 && indicators.length === 0)
751
+ return;
752
+ if (rows && typeof rows === "object" && !Array.isArray(rows)) {
753
+ const rec = rows;
754
+ rec.partial = true;
755
+ if (indicators.length > 0)
756
+ rec.omittedIndicators = indicators;
757
+ if (securities.length > 0)
758
+ rec.omittedSecurities = securities;
759
+ }
708
760
  const parts = [];
709
761
  if (indicators.length > 0)
710
762
  parts.push(`indicators ${indicators.join(", ")}`);
711
763
  if (securities.length > 0)
712
764
  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`);
765
+ process.stderr.write(`[gangtise] warning: the response omits ${parts.join(" and ")} entirely — no row/column at all, not a null one. A code the server merely has no data for still comes back as null, so this normally means the code itself was not recognised: check it for typos and, for securities, for the wrong market suffix (US tickers are .O/.N, not .US). Result marked partial (exit 3).\n`);
766
+ }
767
+ /** `--indicator` / `--security` are repeatable, so Commander cannot mark them
768
+ * required — but every matrix endpoint needs at least one of each and answers a
769
+ * missing one with 100001, whose hint sends the user to `--help`, which in turn
770
+ * showed them as optional with a `[]` default. Catch it here: no request, no
771
+ * round trip, and a message that names the flag. */
772
+ function requireIndicatorScope(indicators, securities) {
773
+ const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
774
+ if (missing.length > 0) {
775
+ throw new ValidationError(`${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} required (repeat the flag for multiple values)`);
715
776
  }
716
777
  }
717
778
  const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series, screener");
@@ -723,35 +784,82 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
723
784
  });
724
785
  await printData(unwrapIndicatorData(raw), format, options.output);
725
786
  }));
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) => {
787
+ 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 — the server resolves that to the enclosing report period for report-period indicators, or pass --indicator-param 'code:reportDate=...' to be explicit", 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
788
  const format = parseOutputFormat(options.format);
789
+ requireIndicatorScope(options.indicator, options.security);
728
790
  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);
791
+ const data = requireIndicatorMatrix(raw);
792
+ // Flatten first: a shape error must not be preceded by a dropped-rows warning
793
+ // that reads like the run merely came back short.
794
+ const rows = flattenCrossSection(data, options.keyBy);
795
+ flagDropped(rows, data, options.security, options.indicator);
796
+ await printData(rows, format, options.output);
732
797
  }));
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) => {
798
+ 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
799
  const format = parseOutputFormat(options.format);
800
+ requireIndicatorScope(options.indicator, options.security);
735
801
  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);
802
+ const data = requireIndicatorMatrix(raw);
803
+ // Pass the universe itself, not a count: flattenTimeSeries needs to know
804
+ // whether a sector ID is in play (the server expands it, so one entry can mean
805
+ // many securities) and dedupes internally. The request only breaks the tie when
806
+ // the response is 1×1 anyway. Flatten before flagging so a shape error surfaces
807
+ // on its own.
808
+ const rows = flattenTimeSeries(data, options.keyBy, options.security);
809
+ flagDropped(rows, data, options.security, options.indicator);
810
+ await printData(rows, format, options.output);
742
811
  }));
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) => {
812
+ 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
813
  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
- }
814
+ requireIndicatorScope(options.indicator, options.security);
815
+ // Binding one indicator code to several variables (the same price on two
816
+ // dates) is supported: the server used to answer every such binding from the
817
+ // EARLIEST date among them and null the rest, which made the whole result
818
+ // untrustworthy and needed an `unreliable` flag. Re-probed 2026-08-08 — fixed:
819
+ // each variable now carries its own date's value, stable across repeats.
820
+ const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
749
821
  const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
750
822
  // Same payload shape as cross-section (one row per matched security, one
751
823
  // 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
824
+ // flag here: a security missing from a screener result means it failed the
753
825
  // filter, which is the whole point.
754
- await printData(flattenCrossSection(unwrapIndicatorData(raw), options.keyBy), format, options.output);
826
+ const data = requireIndicatorMatrix(raw);
827
+ // Before anything is rendered: the returned bindings must be the ones that
828
+ // were asked for, and the expression must still be evaluable from the columns
829
+ // that came back — otherwise the rows cannot be shown to satisfy the
830
+ // conditions they claim to. See checkScreenerBindings for how a missing column
831
+ // is weighed against the expression's boolean structure.
832
+ // Flatten first: it asserts the payload's structural axes, and a response
833
+ // missing `indicatorList` outright deserves that diagnosis rather than being
834
+ // reported as a binding problem. Nothing renders in between, so ordering the
835
+ // structural check ahead of the semantic one is free.
836
+ const rows = flattenCrossSection(data, options.keyBy);
837
+ const filteredOn = screenerExpressionFields(options.expression);
838
+ const unbound = checkScreenerBindings(data, bindings, options.expression);
839
+ // Same ambiguity as the other matrix commands: an empty screen is a normal
840
+ // answer AND what a wrong parameter name produces. Keyed on "nothing matched"
841
+ // rather than the strict canonical-empty shape — a response that returns zero
842
+ // securities while still echoing `indicatorList` is just as empty to the
843
+ // caller, and just as ambiguous, but would slip past isEmptyMatrix.
844
+ // flattenCrossSection above already asserted this is an array of non-empty
845
+ // strings, so only its length is left to read.
846
+ if (data.securityCodeList.length === 0) {
847
+ process.stderr.write("[gangtise] note: nothing matched the expression. That is a normal answer — but an empty result is ALSO what an unrecognised code or a wrong parameter name produces. Cross-check the indicator codes and parameters against 'gangtise indicator search --format json'.\n");
848
+ }
849
+ if (unbound.length > 0) {
850
+ // Whatever reached here still leaves the expression evaluable (or was never
851
+ // read by it), so the rows stand: losing the column costs information, not
852
+ // correctness, and it degrades rather than failing.
853
+ const rec = rows;
854
+ rec.partial = true;
855
+ rec.omittedIndicators = unbound.map((field) => bindings.find((b) => b.field === field)?.indicatorCode ?? field);
856
+ const alsoFiltered = unbound.filter((field) => filteredOn.includes(field));
857
+ const filterNote = alsoFiltered.length > 0
858
+ ? ` 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.`
859
+ : "";
860
+ process.stderr.write(`[gangtise] warning: ${unbound.join(", ")} produced no column at all — those output values are missing. An indicator the server merely has no data for still returns a null column, so this normally means the bound code was not recognised; check it against 'gangtise indicator search'.${filterNote} Result marked partial (exit 3).\n`);
861
+ }
862
+ await printData(rows, format, options.output);
755
863
  }));
756
864
  program.addCommand(indicator);
757
865
  const tool = new Command("tool").description("Research tool APIs: PDF parsing");
@@ -832,12 +940,64 @@ async function checkForUpdate(timeoutMs = 2000) {
832
940
  }
833
941
  catch { /* best-effort: offline or a slow registry must not break --version */ }
834
942
  }
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.
943
+ /** Last-resort reporting for anything that escapes main()'s try/catch: an error
944
+ * thrown inside an event callback or a rejected promise nobody awaited. Node's
945
+ * default is a multi-line crash dump on stdout/stderr AND a non-zero exit — so a
946
+ * command whose data was already printed correctly would end up looking like a
947
+ * hard failure, with a stack trace where a message belongs. This release is
948
+ * about exit codes meaning what they say; that is the one path that lies.
949
+ *
950
+ * The stack is kept behind --verbose: reaching here at all means a CLI bug
951
+ * rather than an API failure, and one line is not enough to locate one. */
952
+ /** Milliseconds to let already-queued stdout reach the pipe before leaving. */
953
+ const FATAL_FLUSH_MS = 200;
954
+ let leaving = false;
955
+ function reportFatal(error) {
956
+ // `error.stack` already opens with "Name: message" — printing both duplicates
957
+ // the first line.
958
+ const stack = error instanceof Error ? error.stack : undefined;
959
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
960
+ const text = `${isVerbose() && stack ? stack : message}\n`;
961
+ if (leaving) {
962
+ process.stderr.write(text);
963
+ return;
964
+ }
965
+ leaving = true;
966
+ process.exitCode = 1;
967
+ // Terminating is not optional: a fatal error with a live handle (a timer, an
968
+ // open socket) would otherwise keep the process running forever on `exitCode`
969
+ // alone. But `process.exit()` on the spot truncates whatever is still queued —
970
+ // to a pipe BOTH streams are written asynchronously — and a handler meant to
971
+ // stop this release from lying about exit codes must not start dropping output
972
+ // to do it. So wait for the diagnostic itself AND for anything stdout still
973
+ // owes, each bounded by the same deadline.
974
+ //
975
+ // stderr is not merely "check the length": the write is issued right here, so
976
+ // its callback is the only thing that knows when it actually reached the pipe.
977
+ // Exiting on the spot truncated a large diagnostic to one 64 KiB buffer.
978
+ let pending = 1;
979
+ const leave = () => { if (--pending === 0)
980
+ process.exit(1); };
981
+ if (process.stdout.writableLength > 0) {
982
+ pending++;
983
+ process.stdout.once("drain", leave);
984
+ }
985
+ setTimeout(() => process.exit(1), FATAL_FLUSH_MS);
986
+ process.stderr.write(text, leave);
987
+ }
988
+ process.on("uncaughtException", reportFatal);
989
+ process.on("unhandledRejection", reportFatal);
990
+ /** Teardown races on a closed stdout: the reader went away, which is not this
991
+ * process's failure. `gangtise ... | head` is the everyday case — it truncates
992
+ * the output and still exits 0, so a same-class race has no principled reason to
993
+ * exit 1. Without a handler the final write crashes Node with an unhandled
994
+ * 'error' event; rethrowing from this callback did the same by another route
995
+ * (a crash dump appended AFTER the correct JSON had already been written). */
996
+ const READER_GONE = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "EBADF"]);
837
997
  process.stdout.on("error", (error) => {
838
- if (error?.code === "EPIPE")
998
+ if (error?.code && READER_GONE.has(error.code))
839
999
  process.exit(0);
840
- throw error;
1000
+ reportFatal(error);
841
1001
  });
842
1002
  async function main() {
843
1003
  // Positional check, not argv.includes: "--version" appearing later (e.g. as