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/README.md +17 -8
- package/dist/src/cli.js +216 -56
- package/dist/src/core/args.js +95 -17
- package/dist/src/core/client.js +4 -3
- package/dist/src/core/commandBodies.js +20 -11
- package/dist/src/core/endpoints.js +18 -0
- package/dist/src/core/errors.js +10 -3
- package/dist/src/core/indicatorMatrix.js +312 -48
- package/dist/src/core/printer.js +5 -3
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +41 -16
- package/gangtise-openapi/references/commands/indicator.md +121 -38
- package/gangtise-openapi/references/commands/insight.md +51 -1
- package/gangtise-openapi/references/commands/quote.md +1 -1
- package/gangtise-openapi/references/commands/reference-and-lookup.md +4 -4
- package/gangtise-openapi/references/commands/vault.md +2 -2
- package/gangtise-openapi/references/examples.md +36 -12
- package/gangtise-openapi/references/lookup-ids.md +2 -2
- package/gangtise-openapi/references/response-schema.md +6 -2
- package/package.json +1 -1
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,
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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").
|
|
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
|
-
/**
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
|
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 =
|
|
730
|
-
|
|
731
|
-
|
|
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 =
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
// and
|
|
740
|
-
//
|
|
741
|
-
|
|
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 —
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
//
|
|
824
|
+
// flag here: a security missing from a screener result means it failed the
|
|
753
825
|
// filter, which is the whole point.
|
|
754
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
|
998
|
+
if (error?.code && READER_GONE.has(error.code))
|
|
839
999
|
process.exit(0);
|
|
840
|
-
|
|
1000
|
+
reportFatal(error);
|
|
841
1001
|
});
|
|
842
1002
|
async function main() {
|
|
843
1003
|
// Positional check, not argv.includes: "--version" appearing later (e.g. as
|