gangtise-openapi-cli 0.34.1 → 0.35.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 +14 -5
- package/dist/src/cli.js +4 -268
- package/dist/src/core/args.js +19 -136
- package/dist/src/core/asyncContent.js +0 -22
- package/dist/src/core/auth.js +0 -14
- package/dist/src/core/client.js +5 -152
- package/dist/src/core/commandBodies.js +7 -35
- package/dist/src/core/download.js +0 -39
- package/dist/src/core/endpoints.js +0 -31
- package/dist/src/core/errors.js +53 -86
- package/dist/src/core/fileParse.js +0 -16
- package/dist/src/core/indicatorMatrix.js +0 -207
- package/dist/src/core/normalize.js +0 -14
- package/dist/src/core/output.js +0 -56
- package/dist/src/core/printer.js +0 -19
- package/dist/src/core/quoteSharding.js +0 -58
- package/dist/src/core/titleCache.js +0 -71
- package/dist/src/core/transport.js +0 -45
- package/dist/src/version.js +1 -2
- package/gangtise-openapi/SKILL.md +2 -2
- package/gangtise-openapi/references/commands/indicator.md +24 -5
- package/gangtise-openapi/references/commands/insight.md +26 -5
- package/gangtise-openapi/references/examples.md +2 -1
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -14,44 +14,19 @@ import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "./co
|
|
|
14
14
|
import { normalizeRows, zipFieldRow } from "./core/normalize.js";
|
|
15
15
|
import { parseOutputFormat } from "./core/output.js";
|
|
16
16
|
import { printData } from "./core/printer.js";
|
|
17
|
-
// --- Lazy-loaded modules (deferred to action handlers) ---
|
|
18
17
|
async function createClient() {
|
|
19
18
|
const { GangtiseClient } = await import("./core/client.js");
|
|
20
19
|
return new GangtiseClient(loadConfig());
|
|
21
20
|
}
|
|
22
|
-
/**
|
|
23
|
-
* Acquire a client, run `produce` to fetch data, and render it through the
|
|
24
|
-
* shared pipeline. Collapses the `createClient()` + `printData(await client.call(...),
|
|
25
|
-
* parseOutputFormat(options.format), options.output)` boilerplate that every
|
|
26
|
-
* query command repeated.
|
|
27
|
-
*/
|
|
28
21
|
async function emit(options, produce, cache) {
|
|
29
|
-
// Validate --format before fetching: a typo'd format must not burn a full
|
|
30
|
-
// (possibly credit-metered) data pull only to fail at render time.
|
|
31
22
|
const format = parseOutputFormat(options.format);
|
|
32
23
|
const client = await createClient();
|
|
33
24
|
await printData(await produce(client), format, options.output, cache);
|
|
34
25
|
}
|
|
35
|
-
/** Acquire a client and run an arbitrary action (downloads, polling, custom shaping). */
|
|
36
26
|
async function withClient(fn) {
|
|
37
27
|
await fn(await createClient());
|
|
38
28
|
}
|
|
39
|
-
/**
|
|
40
|
-
* Server-side default row cap shared by the limit-capped, non-paginated quote endpoints
|
|
41
|
-
* (fund-flow, minute-kline, day/index kline — all default to 6000 per the API docs). We
|
|
42
|
-
* send it EXPLICITLY when `--limit` is omitted (rather than letting the server apply its
|
|
43
|
-
* own default) so the request limit and the truncation `cap` below are always the same
|
|
44
|
-
* number — never a guess about the server's default that can drift out of sync.
|
|
45
|
-
*/
|
|
46
29
|
const DEFAULT_QUOTE_LIMIT = 6000;
|
|
47
|
-
/**
|
|
48
|
-
* Limit-capped, non-paginated endpoints (fund-flow, kline) report `total` as the
|
|
49
|
-
* RETURNED row count, not the true total, so a full page (rows == the limit we sent) is
|
|
50
|
-
* the only truncation signal. Flag the result partial (printData → exit 3) + warn so a
|
|
51
|
-
* capped export isn't mistaken for the full set. `cap` MUST be the exact limit the caller
|
|
52
|
-
* sent on the request; `--limit` is validated to <= 10000 so `cap` can't exceed the
|
|
53
|
-
* server ceiling and hide a truncation.
|
|
54
|
-
*/
|
|
55
30
|
function flagIfLimitTruncated(data, cap, label) {
|
|
56
31
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
57
32
|
return;
|
|
@@ -63,11 +38,6 @@ function flagIfLimitTruncated(data, cap, label) {
|
|
|
63
38
|
process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). Narrow --start-date/--end-date or raise --limit (max 10000), fetching in date batches.\n`);
|
|
64
39
|
}
|
|
65
40
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Run a download. If `output` is set we already know the destination, so the
|
|
68
|
-
* client streams the body straight to disk (no in-memory Uint8Array copy);
|
|
69
|
-
* otherwise we buffer and let the caller resolve a friendly title.
|
|
70
|
-
*/
|
|
71
41
|
async function runDownload(client, endpointKey, query, options) {
|
|
72
42
|
if (options.output) {
|
|
73
43
|
const result = await client.call(endpointKey, options.body, query, { streamTo: options.output });
|
|
@@ -76,15 +46,8 @@ async function runDownload(client, endpointKey, query, options) {
|
|
|
76
46
|
}
|
|
77
47
|
const result = await client.call(endpointKey, options.body, query);
|
|
78
48
|
const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
|
|
79
|
-
// Title-derived names are auto-generated too — dedupe them like the fallback names.
|
|
80
49
|
await saveDownloadResult(result, options.fallbackName, resolved ? await uniquePath(resolved) : undefined);
|
|
81
50
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Register a download subcommand. All download commands share one shape: a
|
|
84
|
-
* required id option, optionally --file-type / --content-type, then --output.
|
|
85
|
-
* `idField` doubles as the commander option key and the query/title-cache
|
|
86
|
-
* field, so it must stay the camelCase twin of `idOption`.
|
|
87
|
-
*/
|
|
88
51
|
function addDownloadCommand(parent, spec) {
|
|
89
52
|
const cmd = parent.command(spec.name ?? "download").requiredOption(`${spec.idOption} <id>`);
|
|
90
53
|
if (spec.fileType) {
|
|
@@ -187,17 +150,6 @@ addTimeFilters(summary.command("list").addOption(new Option("--search-type <numb
|
|
|
187
150
|
categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
|
|
188
151
|
}), { endpointKey: "insight.summary.list", idField: "summaryId" }));
|
|
189
152
|
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
153
|
const PAMIRS_CATEGORIES = ["companyAnalysis", "industryAnalysis"];
|
|
202
154
|
const PAMIRS_MARKETS = ["aShares", "hkStocks", "usChinaConcept", "usStocks"];
|
|
203
155
|
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: accepts BOTH industry code sets -- citicIndustry (1008001xx) and swIndustry (104xx0000) -- but NOT gangtiseIndustry direction codes (122000xxx), which return 0 on this endpoint only", 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", {
|
|
@@ -258,20 +210,7 @@ addScheduleList(siteVisit, "insight.site-visit.list", {
|
|
|
258
210
|
});
|
|
259
211
|
addScheduleList(strategy, "insight.strategy.list", { institution: true, location: true });
|
|
260
212
|
addScheduleList(forum, "insight.forum.list", { researchArea: true, location: true });
|
|
261
|
-
// Earnings calendar: the only insight list filtered by DATE (--start-date/--end-date
|
|
262
|
-
// on publishDate), not by the --start-time datetime every sibling uses — so it does
|
|
263
|
-
// not go through addTimeFilters. It also takes no --keyword / --rank-type / --search-type.
|
|
264
|
-
/** Row ceiling applied when `--security` is the only thing bounding a
|
|
265
|
-
* performance-calendar fetch. Far above any single company's calendar (a whole
|
|
266
|
-
* A-share history is dozens of rows), far below the 50k the auto-pagination
|
|
267
|
-
* would otherwise pull if the server ever stopped honoring securityList. */
|
|
268
213
|
const SECURITY_ONLY_ROW_CAP = 1000;
|
|
269
|
-
/** Warn + mark partial when a `--security`-only fetch lands on the cap with rows
|
|
270
|
-
* still unfetched: that is the signature of a filter that did not narrow anything,
|
|
271
|
-
* and the rows on screen are then a truncated slice of the whole calendar rather
|
|
272
|
-
* than a company's. `total` decides it — a result that happens to be exactly `cap`
|
|
273
|
-
* rows long IS complete (from + rows covers total) and must stay exit 0, or every
|
|
274
|
-
* automated caller reads a full answer as truncated. */
|
|
275
214
|
function flagIfImplicitCapHit(data, cap, from) {
|
|
276
215
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
277
216
|
return;
|
|
@@ -296,25 +235,12 @@ performanceCalendar.command("list").description("Earnings calendar (业绩预告
|
|
|
296
235
|
.option("--category <name>", `Event type: ${PERFORMANCE_CATEGORIES.join("/")}`, collectList, [])
|
|
297
236
|
.option("--format <format>", "Output format", "table").option("--output <path>", "Output path")
|
|
298
237
|
.action((options) => {
|
|
299
|
-
// Enum typos first: a misspelled --category is the likelier mistake, and its
|
|
300
|
-
// message is the more useful one when both checks would fire.
|
|
301
238
|
const marketList = parseChoiceList(options.market, "--market", PERFORMANCE_MARKETS);
|
|
302
239
|
const categoryList = parseChoiceList(options.category, "--category", PERFORMANCE_CATEGORIES);
|
|
303
|
-
// Unfiltered, this endpoint holds >120k rows (probed 2026-07-25: 126683, it
|
|
304
|
-
// also carries FUTURE scheduled events) and an omitted --size means "fetch
|
|
305
|
-
// everything" — 50k rows at the 1000-page cap, ~5000 credits at 0.1/row.
|
|
306
|
-
// Require a bound: a full date range, an explicit --size, or a security filter.
|
|
307
240
|
const explicitlyBounded = Boolean(options.size) || Boolean(options.startDate && options.endDate);
|
|
308
241
|
if (!explicitlyBounded && !options.security.length) {
|
|
309
242
|
throw new ValidationError("insight performance-calendar list without a bound would auto-paginate the whole calendar (>120k rows at 0.1 credits each): pass --start-date and --end-date, or --security, or an explicit --size");
|
|
310
243
|
}
|
|
311
|
-
// --security is only a real bound while the server honors securityList. It does
|
|
312
|
-
// today (probed 2026-07-25: an unknown or malformed code returns total 0, it is
|
|
313
|
-
// not silently ignored like a bad enum) — but a five-figure credit bill must not
|
|
314
|
-
// rest on that staying true. When --security is the ONLY bound, cap the fetch:
|
|
315
|
-
// one company's whole calendar is dozens of rows, so the cap is invisible in
|
|
316
|
-
// normal use and turns a filter regression into a truncated result (partial +
|
|
317
|
-
// exit 3) instead of a 5000-credit pull.
|
|
318
244
|
const implicitCap = explicitlyBounded ? undefined : SECURITY_ONLY_ROW_CAP;
|
|
319
245
|
const from = parseFrom(options.from);
|
|
320
246
|
return emit(options, async (client) => {
|
|
@@ -349,12 +275,6 @@ addTimeFilters(foreignReport.command("list").addOption(new Option("--search-type
|
|
|
349
275
|
minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
|
|
350
276
|
}), { endpointKey: "insight.foreign-report.list", idField: "reportId" }));
|
|
351
277
|
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" });
|
|
352
|
-
// Contract: A-share announcement startTime/endTime go out as 13-digit epoch millis
|
|
353
|
-
// (parseTimestamp13), while HK/US announcement and every other insight list send the
|
|
354
|
-
// datetime string straight through. All three filter correctly — verified live against
|
|
355
|
-
// a narrow past window (each returns in-window rows). A-share's API also accepts the
|
|
356
|
-
// string form, but the 13-digit conversion is kept as the historical spec contract;
|
|
357
|
-
// don't "unify" it away without re-confirming the A-share announcement spec.
|
|
358
278
|
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", {
|
|
359
279
|
from: parseFrom(options.from), size: parseSize(options.size),
|
|
360
280
|
startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
|
|
@@ -380,7 +300,7 @@ addTimeFilters(announcementUs.command("list").addOption(new Option("--search-typ
|
|
|
380
300
|
securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
|
|
381
301
|
}), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
|
|
382
302
|
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 --
|
|
303
|
+
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 -- only 'gl' returns rows on this endpoint; cn/us/jp come back as a null payload with no error. Filter on the region field client-side", collectList, []).option("--industry <id>", "Industry ID -- NOT effective on this endpoint: any value yields a null payload (not an empty list), with no error. Omit it and filter on industryList[] client-side", 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", {
|
|
384
304
|
from: parseFrom(options.from), size: parseSize(options.size),
|
|
385
305
|
startTime: options.startTime, endTime: options.endTime,
|
|
386
306
|
rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
|
|
@@ -408,8 +328,6 @@ addTimeFilters(officialAccount.command("list").addOption(new Option("--search-ty
|
|
|
408
328
|
categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
|
|
409
329
|
}), { endpointKey: "insight.official-account.list", idField: "articleId" }));
|
|
410
330
|
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" });
|
|
411
|
-
// QA request keys are BARE (source/questionCategory/answerImportant), not the *List
|
|
412
|
-
// convention — the body below mirrors the spec exactly. Datetimes pass through as strings.
|
|
413
331
|
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", {
|
|
414
332
|
from: parseFrom(options.from), size: parseSize(options.size),
|
|
415
333
|
securityCode: options.securityCode, startTime: options.startTime, endTime: options.endTime,
|
|
@@ -443,66 +361,24 @@ program.addCommand(insight);
|
|
|
443
361
|
const quote = new Command("quote").description("Quote APIs");
|
|
444
362
|
const KLINE_MARKETS = { aShares: 1, hkStocks: 2, usStocks: 1 };
|
|
445
363
|
const LEGACY_ALL_MARKET = (shardDays) => ({ all: shardDays });
|
|
446
|
-
/** Realtime takes the same keywords but returns one snapshot per security, so there is
|
|
447
|
-
* nothing to shard and it only needs the accepted-keyword list. */
|
|
448
364
|
const REALTIME_MARKETS = ["aShares", "hkStocks", "usStocks"];
|
|
449
|
-
/** fund-flow is A-share only, so `aShares` is its sole whole-market keyword. */
|
|
450
365
|
const FUND_FLOW_MARKETS = ["aShares"];
|
|
451
|
-
/** Every keyword the quote APIs have ever taken, including ones a given command no
|
|
452
|
-
* longer accepts. What an unrecognised keyword does depends on the endpoint, and BOTH
|
|
453
|
-
* outcomes are worth a local error: the unified `day-kline` / `realtime` / `fund-flow`
|
|
454
|
-
* answer `120001` "invalid security code" (which sends the user hunting for a typo in a
|
|
455
|
-
* code that is fine), while the menu-retired per-market endpoints answer `code=000000`
|
|
456
|
-
* with `total: 0` — a silent empty result indistinguishable from "no data".
|
|
457
|
-
*
|
|
458
|
-
* Compared lower-cased, and 🔴 **that folding is load-bearing, not tidiness** — the API's
|
|
459
|
-
* own case handling differs BY ENDPOINT (probed 2026-08-15, all six):
|
|
460
|
-
*
|
|
461
|
-
* folds case: day-kline (aShares/…), realtime, day-kline-hk/-us/index (all)
|
|
462
|
-
* case-SENSITIVE: fund-flow — only the literal `aShares` works; `ashares` / `ASHARES`
|
|
463
|
-
* come back as `120001 非有效A股`
|
|
464
|
-
*
|
|
465
|
-
* So on five endpoints canonicalising merely keeps our shard lookup in step with the
|
|
466
|
-
* server (drop it and a case variant degrades to an unsharded 6000-row request), but on
|
|
467
|
-
* `fund-flow` it is the ONLY reason `--security ashares` works at all. Removing
|
|
468
|
-
* `canonicalizeMarketKeywords` would turn that from a working query into a hard error —
|
|
469
|
-
* see the fund-flow case test, which exists to pin exactly that.
|
|
470
|
-
*
|
|
471
|
-
* ⚠️ `all` collides with a real ticker root (`ALL` is Allstate on the NYSE), so a bare
|
|
472
|
-
* `--security ALL` fetches the whole US market instead of that stock. That resolution
|
|
473
|
-
* happens on the SERVER (`ALL` / `All` / `all` are equivalent to it on all three retired
|
|
474
|
-
* endpoints), so matching case-sensitively here would not prevent it — it would only stop
|
|
475
|
-
* us from sharding a request the server treats as whole-market anyway, turning a complete
|
|
476
|
-
* result into a 6000-row truncation. The fix for that user is the suffixed `ALL.N`.
|
|
477
|
-
*
|
|
478
|
-
* Unknown keywords are deliberately NOT rejected — this is a known-keyword list, so a
|
|
479
|
-
* future server-side addition degrades to "unsharded" rather than being refused outright
|
|
480
|
-
* (fail-open, no enum drift). */
|
|
481
366
|
const MARKET_KEYWORDS = new Set(["all", "ashares", "hkstocks", "usstocks"]);
|
|
482
367
|
const matchesMarketKeyword = (value, keyword) => value.toLowerCase() === keyword.toLowerCase();
|
|
483
368
|
const checkMarketKeywords = (securities, accepted, command) => {
|
|
484
369
|
const used = securities.filter((s) => MARKET_KEYWORDS.has(s.toLowerCase()));
|
|
485
370
|
if (used.length === 0)
|
|
486
371
|
return;
|
|
487
|
-
// Report an unsupported keyword before the alone-ness rule: when both are wrong, the
|
|
488
|
-
// keyword itself is the thing the user has to change.
|
|
489
372
|
const unsupported = used.filter((k) => !accepted.some((a) => matchesMarketKeyword(k, a)));
|
|
490
373
|
if (unsupported.length > 0) {
|
|
491
374
|
throw new ValidationError(accepted.length > 0
|
|
492
375
|
? `${command}: '${unsupported[0]}' is not a whole-market keyword for this command — use ${accepted.join(" / ")}`
|
|
493
376
|
: `${command}: this command takes explicit security codes only — '${unsupported[0]}' and other whole-market keywords are not supported`);
|
|
494
377
|
}
|
|
495
|
-
// The API rejects a keyword sent alongside security codes or a second keyword, again
|
|
496
|
-
// as a bare 120001 that points at the codes rather than at the combination. On
|
|
497
|
-
// `fund-flow` it is worse than a rejection: the keyword is silently dropped and only
|
|
498
|
-
// the explicit codes come back, exit 0.
|
|
499
378
|
if (securities.length > 1) {
|
|
500
379
|
throw new ValidationError(`${command}: a market keyword must be passed alone, got '${securities.join(", ")}' — the API rejects it mixed with security codes or another keyword`);
|
|
501
380
|
}
|
|
502
381
|
};
|
|
503
|
-
/** Fold a user-typed keyword back to the spelling the sharding lookup expects, so a case
|
|
504
|
-
* variant reaches the same code path as the canonical form. Non-keywords pass through
|
|
505
|
-
* untouched. */
|
|
506
382
|
const canonicalizeMarketKeywords = (securities, accepted) => securities.map((s) => accepted.find((a) => matchesMarketKeyword(s, a)) ?? s);
|
|
507
383
|
const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.command(name)
|
|
508
384
|
.option("--security <code>", securityHelp, collectList, [])
|
|
@@ -513,30 +389,18 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
|
|
|
513
389
|
.option("--format <format>", "Output format", "table")
|
|
514
390
|
.option("--output <path>")
|
|
515
391
|
.action((options) => {
|
|
516
|
-
// Validate BEFORE withClient: createClient() logs in when no token is cached, so a
|
|
517
|
-
// check inside the callback would spend a request to then fail locally anyway.
|
|
518
392
|
checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`);
|
|
519
393
|
options.security = canonicalizeMarketKeywords(options.security, Object.keys(markets));
|
|
520
394
|
return withClient(async (client) => {
|
|
521
395
|
const format = parseOutputFormat(options.format);
|
|
522
396
|
const body = buildQuoteKlineBody(options);
|
|
523
|
-
// Each market shards at its own granularity, so resolve which keyword was asked
|
|
524
|
-
// for before picking shardDays — a whole-market HK pull tolerates 2-day windows
|
|
525
|
-
// where A-share and US pulls need one day each.
|
|
526
397
|
const keyword = Object.keys(markets).find((k) => isFullMarket(body, k));
|
|
527
398
|
if (keyword) {
|
|
528
|
-
// A whole-market query is date-sharded: callKlineWithSharding lifts the limit to
|
|
529
|
-
// the API max and owns completeness (partial / failedShards), so leave `limit`
|
|
530
|
-
// unset and skip the single-request truncation guard.
|
|
531
399
|
const data = await callKlineWithSharding(client, endpointKey, body, { shardDays: markets[keyword], fullMarketValue: keyword });
|
|
532
400
|
await printData(data, format, options.output);
|
|
533
401
|
return;
|
|
534
402
|
}
|
|
535
|
-
// Explicit securities go out as one request: pin the limit to the known default so
|
|
536
|
-
// the sent limit and the truncation cap are the same number by construction.
|
|
537
403
|
const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
|
|
538
|
-
// (Nothing to shard: sharding only ever applied to whole-market queries, so this
|
|
539
|
-
// is exactly the passthrough callKlineWithSharding would have done.)
|
|
540
404
|
const data = await client.call(endpointKey, { ...body, limit });
|
|
541
405
|
flagIfLimitTruncated(data, limit, name);
|
|
542
406
|
await printData(data, format, options.output);
|
|
@@ -545,10 +409,6 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
|
|
|
545
409
|
addKlineCommand("day-kline", "quote.day-kline", "Security code — A-share .SH/.SZ/.BJ, HK .HK, US .O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI; or one market keyword: aShares / hkStocks / usStocks (auto-sharded by date, must be passed alone)", KLINE_MARKETS);
|
|
546
410
|
addKlineCommand("day-kline-hk", "quote.day-kline-hk", "[deprecated: use 'day-kline'] Security code (HK stock: .HK, or 'all' for full market)", LEGACY_ALL_MARKET(2));
|
|
547
411
|
addKlineCommand("day-kline-us", "quote.day-kline-us", "[deprecated: use 'day-kline'] Security code (US stock: e.g. AAPL.O, or 'all' for full market)", LEGACY_ALL_MARKET(1));
|
|
548
|
-
// 15 days, not the historical 30: ~531 index rows per trading day x ~11 trading days in a
|
|
549
|
-
// 15-day window is ~5.8K, while a 30-day window is ~11.7K — every shard silently maxed out
|
|
550
|
-
// at the 10K cap and lost ~11% of the range (it surfaced as exit 3 + truncatedShards, but
|
|
551
|
-
// the split was never sized to avoid it in the first place).
|
|
552
412
|
addKlineCommand("index-day-kline", "quote.index-day-kline", "[deprecated: use 'day-kline'] Index code (.SH/.SZ/.BJ, or 'all' for full market)", LEGACY_ALL_MARKET(15));
|
|
553
413
|
quote.command("minute-kline").option("--security <code>", "Security code — A-share .SH/.SZ (SH/SZ only), exchange index .SH/.SZ, concept index .GT, industry index .CI/.SWI; no whole-market keyword").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
554
414
|
const format = parseOutputFormat(options.format);
|
|
@@ -558,17 +418,10 @@ quote.command("minute-kline").option("--security <code>", "Security code — A-s
|
|
|
558
418
|
await printData(data, format, options.output);
|
|
559
419
|
}));
|
|
560
420
|
quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US stocks and indices)").option("--security <code>", "Security code — stock .SH/.SZ/.BJ/.HK/.O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI; or one market keyword: aShares / hkStocks / usStocks (must be passed alone; indices have no whole-market keyword)", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
|
|
561
|
-
// Realtime takes the same market keywords as day-kline but never shards (one snapshot
|
|
562
|
-
// per security), so it only needs the "alone, and a keyword this API knows" check.
|
|
563
421
|
checkMarketKeywords(options.security, REALTIME_MARKETS, "quote realtime");
|
|
564
422
|
return emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) }));
|
|
565
423
|
});
|
|
566
424
|
quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 872931.BJ), or 'aShares' for full A-share market — auto-sharded by day (repeat)", collectList, []).option("--start-date <date>", "Start date yyyy-MM-dd (default: endDate minus 1 year)", dateArg("--start-date")).option("--end-date <date>", "End date yyyy-MM-dd (default: latest trading day)", dateArg("--end-date")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000; single-security cap — aShares auto-shards by day)").option("--field <field>", "Field, e.g. mainNetInflow/largeInflow/xlargeOutflow (repeat); omit for all", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
|
|
567
|
-
// fund-flow needs this guard MORE than the kline commands, not less: mixing the keyword
|
|
568
|
-
// with codes doesn't even fail here. The server silently drops `aShares` and answers
|
|
569
|
-
// with just the explicit codes — one row, exit 0, no warning — so "whole market plus
|
|
570
|
-
// this one" quietly becomes "only this one". Validate before withClient so no login
|
|
571
|
-
// request is spent on a query that fails locally.
|
|
572
425
|
checkMarketKeywords(options.security, FUND_FLOW_MARKETS, "quote fund-flow");
|
|
573
426
|
options.security = canonicalizeMarketKeywords(options.security, FUND_FLOW_MARKETS);
|
|
574
427
|
return withClient(async (client) => {
|
|
@@ -581,11 +434,6 @@ quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").opt
|
|
|
581
434
|
fieldList: maybeArray(options.field),
|
|
582
435
|
};
|
|
583
436
|
if (isFullMarket(body, "aShares")) {
|
|
584
|
-
// Full-market fund-flow: the server errors (430012/430013) instead of truncating when
|
|
585
|
-
// a single request exceeds the row cap, so date-shard by day (~5.4k A-share rows/day,
|
|
586
|
-
// under the lifted API cap) and merge — same mechanism as `--security all` kline.
|
|
587
|
-
// Sharding needs an explicit range; without both dates it would fall back to one
|
|
588
|
-
// doomed full-market request, so require the range up front with a clear message.
|
|
589
437
|
if (!body.startDate || !body.endDate) {
|
|
590
438
|
throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
|
|
591
439
|
}
|
|
@@ -637,8 +485,6 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
|
|
|
637
485
|
fundamental.command("top-holders").requiredOption("--security-code <code>").addOption(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory()).option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/interim/q3/annual/latest", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined })));
|
|
638
486
|
fundamental.command("earning-forecast").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date")).option("--end-date <date>", "End date (default: today)", dateArg("--end-date")).option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => {
|
|
639
487
|
const endDate = options.endDate ?? localDateString(new Date());
|
|
640
|
-
// Anchor the default window to endDate (as the help text promises), not to today —
|
|
641
|
-
// a historical --end-date without --start-date should mean "the year before it".
|
|
642
488
|
const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
643
489
|
return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
|
|
644
490
|
}));
|
|
@@ -676,8 +522,6 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
|
|
|
676
522
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
677
523
|
const outcome = await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, format, options.output);
|
|
678
524
|
if (outcome !== "ok") {
|
|
679
|
-
// "failed" already printed its terminal "Do not retry" line — only a timeout
|
|
680
|
-
// gets the retry hint.
|
|
681
525
|
if (outcome === "timeout") {
|
|
682
526
|
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
|
|
683
527
|
}
|
|
@@ -737,12 +581,8 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
|
|
|
737
581
|
}));
|
|
738
582
|
ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient((client) => checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output)));
|
|
739
583
|
ai.command("stock-summary").description("Stock highlights: refined research summary per security (A-share / HK)").option("--security <code>", "Security code (e.g. 600519.SH / 00700.HK), up to 6000 per call; market keywords are NOT supported by this endpoint", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
|
|
740
|
-
// Guard against an empty --security: omitting it would send securityList:undefined,
|
|
741
|
-
// which the backend may treat as all-market (3 credits/row × thousands of rows).
|
|
742
584
|
if (!options.security.length)
|
|
743
585
|
throw new ValidationError("--security is required: pass one or more security codes (A-share / HK), up to 6000 per call");
|
|
744
|
-
// The endpoint dropped whole-market batches on 2026-08-14 and now answers a market
|
|
745
|
-
// keyword with 120001 "invalid security code" — which reads as a typo in the code.
|
|
746
586
|
checkMarketKeywords(options.security, [], "ai stock-summary");
|
|
747
587
|
return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
|
|
748
588
|
});
|
|
@@ -772,7 +612,6 @@ reference.command("institution-search").requiredOption("--keyword <text>", "Sear
|
|
|
772
612
|
categoryList: parseChoiceList(options.category, "--category", ["domesticBroker", "foreignInstitution", "leadInstitution", "opinionInstitution", "foreignOpinionInstitution"]),
|
|
773
613
|
top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
|
|
774
614
|
})));
|
|
775
|
-
// Note: request key is BARE `category` here (spec), unlike institution-search's `categoryList`.
|
|
776
615
|
reference.command("official-account-search").requiredOption("--keyword <text>", "Search keyword (account name / institution / keyword, e.g. 东吴证券)").option("--category <name>", "Category: listedCompany/broker/government/media (repeat); omit for all incl. uncategorized", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.official-account-search", {
|
|
777
616
|
keyword: options.keyword,
|
|
778
617
|
category: parseChoiceList(options.category, "--category", ["listedCompany", "broker", "government", "media"]),
|
|
@@ -815,29 +654,7 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
|
|
|
815
654
|
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 })));
|
|
816
655
|
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 })));
|
|
817
656
|
program.addCommand(alternative);
|
|
818
|
-
/** Mark and report the request codes the server did not answer for at all.
|
|
819
|
-
*
|
|
820
|
-
* What this catches changed on the server. EDE used to drop any axis it had no
|
|
821
|
-
* DATA for — an indicator empty for every security vanished from
|
|
822
|
-
* `indicatorList`, a security empty for every indicator vanished from
|
|
823
|
-
* `securityCodeList`. Re-probed 2026-08-08: a coverage gap is now padded with
|
|
824
|
-
* `null` and keeps its row and column (`mgn_bal` × 00700.HK, `finc_pb_mrq` ×
|
|
825
|
-
* 09992.HK — both null, both present, even as the only cell in the request).
|
|
826
|
-
*
|
|
827
|
-
* A code the server cannot RESOLVE still vanishes: an unknown indicator code, or
|
|
828
|
-
* a security code with the wrong suffix (`AAPL.US`, whose real form is
|
|
829
|
-
* `AAPL.O`). So this is now a typo detector rather than a coverage detector —
|
|
830
|
-
* which is the more useful of the two, since a coverage gap is visible as `null`
|
|
831
|
-
* but a misspelled code is otherwise invisible: exit code 0, a plausible-looking
|
|
832
|
-
* table, and a `--key-by code` mapping whose key simply is not there.
|
|
833
|
-
*
|
|
834
|
-
* Same signal as a failed page or a row cap: `partial` on the payload (printData
|
|
835
|
-
* → exit 3) plus the omitted codes, so an automated caller can react without
|
|
836
|
-
* parsing stderr. */
|
|
837
657
|
function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
|
|
838
|
-
// A wholly empty response is not a partial one: the diff against the request
|
|
839
|
-
// would list everything as "omitted", which says nothing about which axis is
|
|
840
|
-
// at fault. Exit 0, but say why it is ambiguous.
|
|
841
658
|
if (isEmptyMatrix(data)) {
|
|
842
659
|
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");
|
|
843
660
|
return;
|
|
@@ -860,11 +677,6 @@ function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
|
|
|
860
677
|
parts.push(`securities ${securities.join(", ")}`);
|
|
861
678
|
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`);
|
|
862
679
|
}
|
|
863
|
-
/** `--indicator` / `--security` are repeatable, so Commander cannot mark them
|
|
864
|
-
* required — but every matrix endpoint needs at least one of each and answers a
|
|
865
|
-
* missing one with 100001, whose hint sends the user to `--help`, which in turn
|
|
866
|
-
* showed them as optional with a `[]` default. Catch it here: no request, no
|
|
867
|
-
* round trip, and a message that names the flag. */
|
|
868
680
|
function requireIndicatorScope(indicators, securities) {
|
|
869
681
|
const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
|
|
870
682
|
if (missing.length > 0) {
|
|
@@ -880,13 +692,11 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
|
|
|
880
692
|
});
|
|
881
693
|
await printData(unwrapIndicatorData(raw), format, options.output);
|
|
882
694
|
}));
|
|
883
|
-
indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate. Report-period indicators (is_*, financial statements) REJECT tradeDate and require --indicator-param 'code:reportDate=yyyy-MM-dd' instead — check parameterList in 'indicator search'", 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) => {
|
|
695
|
+
indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate. Report-period indicators (is_*, financial statements) REJECT tradeDate and require --indicator-param 'code:reportDate=yyyy-MM-dd' instead — check parameterList in 'indicator search'", 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'. Bare 'code:' (nothing after the colon) declares the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_mkt, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'code:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'code:' + 'code:scale=8' keeps the scale", 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) => {
|
|
884
696
|
const format = parseOutputFormat(options.format);
|
|
885
697
|
requireIndicatorScope(options.indicator, options.security);
|
|
886
698
|
const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
|
|
887
699
|
const data = requireIndicatorMatrix(raw);
|
|
888
|
-
// Flatten first: a shape error must not be preceded by a dropped-rows warning
|
|
889
|
-
// that reads like the run merely came back short.
|
|
890
700
|
const rows = flattenCrossSection(data, options.keyBy);
|
|
891
701
|
flagDropped(rows, data, options.security, options.indicator);
|
|
892
702
|
await printData(rows, format, options.output);
|
|
@@ -896,56 +706,23 @@ indicator.command("time-series").option("--indicator <code>", "Indicator code, e
|
|
|
896
706
|
requireIndicatorScope(options.indicator, options.security);
|
|
897
707
|
const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
|
|
898
708
|
const data = requireIndicatorMatrix(raw);
|
|
899
|
-
// Pass the universe itself, not a count: flattenTimeSeries needs to know
|
|
900
|
-
// whether a sector ID is in play (the server expands it, so one entry can mean
|
|
901
|
-
// many securities) and dedupes internally. The request only breaks the tie when
|
|
902
|
-
// the response is 1×1 anyway. Flatten before flagging so a shape error surfaces
|
|
903
|
-
// on its own.
|
|
904
709
|
const rows = flattenTimeSeries(data, options.keyBy, options.security);
|
|
905
710
|
flagDropped(rows, data, options.security, options.indicator);
|
|
906
711
|
await printData(rows, format, options.output);
|
|
907
712
|
}));
|
|
908
|
-
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. Report-period indicators (is_*) reject tradeDate: give them --indicator-param 'F1:reportDate=yyyy-MM-dd'", 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) => {
|
|
713
|
+
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. Report-period indicators (is_*) reject tradeDate: give them --indicator-param 'F1:reportDate=yyyy-MM-dd'", dateArg("--date")).option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'. The cross-section no-date opt-out ('F1:' with nothing after the colon) is REJECTED here: an indicator sent with no parameters does not take part in the screen and the query comes back as 0 rows with no error, so indicators whose parameterList has no date key (the pty_* / scr_* families, div_cash_yr, pty_shr_reg) cannot be screened on at all — read them with 'indicator cross-section' and filter client-side", 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) => {
|
|
909
714
|
const format = parseOutputFormat(options.format);
|
|
910
715
|
requireIndicatorScope(options.indicator, options.security);
|
|
911
|
-
// Binding one indicator code to several variables (the same price on two
|
|
912
|
-
// dates) is supported: the server used to answer every such binding from the
|
|
913
|
-
// EARLIEST date among them and null the rest, which made the whole result
|
|
914
|
-
// untrustworthy and needed an `unreliable` flag. Re-probed 2026-08-08 — fixed:
|
|
915
|
-
// each variable now carries its own date's value, stable across repeats.
|
|
916
716
|
const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
|
|
917
717
|
const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
|
|
918
|
-
// Same payload shape as cross-section (one row per matched security, one
|
|
919
|
-
// column per indicator) with a `field` on each indicator entry. No dropped-row
|
|
920
|
-
// flag here: a security missing from a screener result means it failed the
|
|
921
|
-
// filter, which is the whole point.
|
|
922
718
|
const data = requireIndicatorMatrix(raw);
|
|
923
|
-
// Before anything is rendered: the returned bindings must be the ones that
|
|
924
|
-
// were asked for, and the expression must still be evaluable from the columns
|
|
925
|
-
// that came back — otherwise the rows cannot be shown to satisfy the
|
|
926
|
-
// conditions they claim to. See checkScreenerBindings for how a missing column
|
|
927
|
-
// is weighed against the expression's boolean structure.
|
|
928
|
-
// Flatten first: it asserts the payload's structural axes, and a response
|
|
929
|
-
// missing `indicatorList` outright deserves that diagnosis rather than being
|
|
930
|
-
// reported as a binding problem. Nothing renders in between, so ordering the
|
|
931
|
-
// structural check ahead of the semantic one is free.
|
|
932
719
|
const rows = flattenCrossSection(data, options.keyBy);
|
|
933
720
|
const filteredOn = screenerExpressionFields(options.expression);
|
|
934
721
|
const unbound = checkScreenerBindings(data, bindings, options.expression);
|
|
935
|
-
// Same ambiguity as the other matrix commands: an empty screen is a normal
|
|
936
|
-
// answer AND what a wrong parameter name produces. Keyed on "nothing matched"
|
|
937
|
-
// rather than the strict canonical-empty shape — a response that returns zero
|
|
938
|
-
// securities while still echoing `indicatorList` is just as empty to the
|
|
939
|
-
// caller, and just as ambiguous, but would slip past isEmptyMatrix.
|
|
940
|
-
// flattenCrossSection above already asserted this is an array of non-empty
|
|
941
|
-
// strings, so only its length is left to read.
|
|
942
722
|
if (data.securityCodeList.length === 0) {
|
|
943
723
|
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");
|
|
944
724
|
}
|
|
945
725
|
if (unbound.length > 0) {
|
|
946
|
-
// Whatever reached here still leaves the expression evaluable (or was never
|
|
947
|
-
// read by it), so the rows stand: losing the column costs information, not
|
|
948
|
-
// correctness, and it degrades rather than failing.
|
|
949
726
|
const rec = rows;
|
|
950
727
|
rec.partial = true;
|
|
951
728
|
rec.omittedIndicators = unbound.map((field) => bindings.find((b) => b.field === field)?.indicatorCode ?? field);
|
|
@@ -972,8 +749,6 @@ tool.command("file-parse").description("Parse a PDF into Markdown + images (asyn
|
|
|
972
749
|
}
|
|
973
750
|
process.stderr.write(`Got taskId: ${taskId}, waiting for the parse to finish...\n`);
|
|
974
751
|
if (await pollFileParseResult(client, taskId, options.output) !== "ok") {
|
|
975
|
-
// Fetching the result is free and the task keeps running server-side —
|
|
976
|
-
// re-checking later costs nothing, resubmitting re-bills the whole file.
|
|
977
752
|
process.stderr.write(`Parse result not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise tool file-parse-check --task-id ${taskId}\n`);
|
|
978
753
|
process.exitCode = 1;
|
|
979
754
|
}
|
|
@@ -1003,11 +778,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
|
|
|
1003
778
|
throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
|
|
1004
779
|
}
|
|
1005
780
|
}
|
|
1006
|
-
// Fail loudly on arguments the endpoint kind can't use — they used to be
|
|
1007
|
-
// silently dropped, leaving the user to puzzle over server-side errors.
|
|
1008
781
|
if (endpoint.kind === "download") {
|
|
1009
|
-
// POST download endpoints (file-parse result) take their parameters as a JSON
|
|
1010
|
-
// body; GET ones take --query and can't carry a body at all.
|
|
1011
782
|
if (body !== undefined && endpoint.method !== "POST") {
|
|
1012
783
|
throw new ValidationError(`--body is not supported for GET download endpoints (use --query key=value); ${endpointKey} is kind=download`);
|
|
1013
784
|
}
|
|
@@ -1028,29 +799,15 @@ async function checkForUpdate(timeoutMs = 2000) {
|
|
|
1028
799
|
try {
|
|
1029
800
|
const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
|
|
1030
801
|
const latest = (await response.json()).version;
|
|
1031
|
-
// Ordered compare, not inequality: during the just-published window the
|
|
1032
|
-
// registry still serves the PREVIOUS version — don't suggest a "downgrade".
|
|
1033
802
|
if (latest && isVersionNewer(latest, CLI_VERSION)) {
|
|
1034
803
|
process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
|
|
1035
804
|
}
|
|
1036
805
|
}
|
|
1037
|
-
catch {
|
|
806
|
+
catch { }
|
|
1038
807
|
}
|
|
1039
|
-
/** Last-resort reporting for anything that escapes main()'s try/catch: an error
|
|
1040
|
-
* thrown inside an event callback or a rejected promise nobody awaited. Node's
|
|
1041
|
-
* default is a multi-line crash dump on stdout/stderr AND a non-zero exit — so a
|
|
1042
|
-
* command whose data was already printed correctly would end up looking like a
|
|
1043
|
-
* hard failure, with a stack trace where a message belongs. This release is
|
|
1044
|
-
* about exit codes meaning what they say; that is the one path that lies.
|
|
1045
|
-
*
|
|
1046
|
-
* The stack is kept behind --verbose: reaching here at all means a CLI bug
|
|
1047
|
-
* rather than an API failure, and one line is not enough to locate one. */
|
|
1048
|
-
/** Milliseconds to let already-queued stdout reach the pipe before leaving. */
|
|
1049
808
|
const FATAL_FLUSH_MS = 200;
|
|
1050
809
|
let leaving = false;
|
|
1051
810
|
function reportFatal(error) {
|
|
1052
|
-
// `error.stack` already opens with "Name: message" — printing both duplicates
|
|
1053
|
-
// the first line.
|
|
1054
811
|
const stack = error instanceof Error ? error.stack : undefined;
|
|
1055
812
|
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
1056
813
|
const text = `${isVerbose() && stack ? stack : message}\n`;
|
|
@@ -1060,17 +817,6 @@ function reportFatal(error) {
|
|
|
1060
817
|
}
|
|
1061
818
|
leaving = true;
|
|
1062
819
|
process.exitCode = 1;
|
|
1063
|
-
// Terminating is not optional: a fatal error with a live handle (a timer, an
|
|
1064
|
-
// open socket) would otherwise keep the process running forever on `exitCode`
|
|
1065
|
-
// alone. But `process.exit()` on the spot truncates whatever is still queued —
|
|
1066
|
-
// to a pipe BOTH streams are written asynchronously — and a handler meant to
|
|
1067
|
-
// stop this release from lying about exit codes must not start dropping output
|
|
1068
|
-
// to do it. So wait for the diagnostic itself AND for anything stdout still
|
|
1069
|
-
// owes, each bounded by the same deadline.
|
|
1070
|
-
//
|
|
1071
|
-
// stderr is not merely "check the length": the write is issued right here, so
|
|
1072
|
-
// its callback is the only thing that knows when it actually reached the pipe.
|
|
1073
|
-
// Exiting on the spot truncated a large diagnostic to one 64 KiB buffer.
|
|
1074
820
|
let pending = 1;
|
|
1075
821
|
const leave = () => { if (--pending === 0)
|
|
1076
822
|
process.exit(1); };
|
|
@@ -1083,12 +829,6 @@ function reportFatal(error) {
|
|
|
1083
829
|
}
|
|
1084
830
|
process.on("uncaughtException", reportFatal);
|
|
1085
831
|
process.on("unhandledRejection", reportFatal);
|
|
1086
|
-
/** Teardown races on a closed stdout: the reader went away, which is not this
|
|
1087
|
-
* process's failure. `gangtise ... | head` is the everyday case — it truncates
|
|
1088
|
-
* the output and still exits 0, so a same-class race has no principled reason to
|
|
1089
|
-
* exit 1. Without a handler the final write crashes Node with an unhandled
|
|
1090
|
-
* 'error' event; rethrowing from this callback did the same by another route
|
|
1091
|
-
* (a crash dump appended AFTER the correct JSON had already been written). */
|
|
1092
832
|
const READER_GONE = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "EBADF"]);
|
|
1093
833
|
process.stdout.on("error", (error) => {
|
|
1094
834
|
if (error?.code && READER_GONE.has(error.code))
|
|
@@ -1096,8 +836,6 @@ process.stdout.on("error", (error) => {
|
|
|
1096
836
|
reportFatal(error);
|
|
1097
837
|
});
|
|
1098
838
|
async function main() {
|
|
1099
|
-
// Positional check, not argv.includes: "--version" appearing later (e.g. as
|
|
1100
|
-
// another option's value) must not short-circuit the whole command.
|
|
1101
839
|
const firstArg = process.argv[2];
|
|
1102
840
|
if (firstArg === "--version" || firstArg === "-V") {
|
|
1103
841
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
@@ -1110,8 +848,6 @@ async function main() {
|
|
|
1110
848
|
catch (error) {
|
|
1111
849
|
if (error instanceof ApiError) {
|
|
1112
850
|
const hint = error.hint ? ` ${error.hint}` : "";
|
|
1113
|
-
// traceId is what Gangtise support needs to look a failure up; without it a
|
|
1114
|
-
// 999999 report is unactionable on their side.
|
|
1115
851
|
const trace = error.traceId ? ` [trace ${error.traceId}]` : "";
|
|
1116
852
|
process.stderr.write(`API error${error.code ? ` (${error.code})` : ""}${trace}: ${error.message}${hint}\n`);
|
|
1117
853
|
process.exitCode = 1;
|