gangtise-openapi-cli 0.41.0 → 0.42.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 +48 -50
- package/dist/src/cli.js +23 -1131
- package/dist/src/commands/ai.js +155 -0
- package/dist/src/commands/alternative.js +56 -0
- package/dist/src/commands/auth.js +37 -0
- package/dist/src/commands/bond.js +76 -0
- package/dist/src/commands/fundamental.js +137 -0
- package/dist/src/commands/indicator.js +134 -0
- package/dist/src/commands/insight.js +337 -0
- package/dist/src/commands/quote.js +147 -0
- package/dist/src/commands/raw.js +49 -0
- package/dist/src/commands/reference.js +58 -0
- package/dist/src/commands/shared.js +145 -0
- package/dist/src/commands/tool.js +55 -0
- package/dist/src/commands/vault.js +203 -0
- package/dist/src/core/args.js +7 -4
- package/dist/src/core/asyncContent.js +32 -19
- package/dist/src/core/client.js +11 -4
- package/dist/src/core/config.js +16 -3
- package/dist/src/core/endpoints.js +3 -0
- package/dist/src/core/exitStatus.js +17 -0
- package/dist/src/core/fileParse.js +3 -20
- package/dist/src/core/opinionDetail.js +39 -0
- package/dist/src/core/output.js +18 -4
- package/dist/src/core/printer.js +7 -8
- package/dist/src/core/rowSink.js +19 -7
- package/dist/src/core/transport.js +4 -3
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +5 -5
- package/gangtise-openapi/references/commands/ai.md +1 -1
- package/gangtise-openapi/references/commands/fundamental.md +3 -1
- package/gangtise-openapi/references/commands/indicator.md +2 -2
- package/gangtise-openapi/references/commands/quote.md +7 -10
- package/gangtise-openapi/references/errors.md +8 -5
- package/gangtise-openapi/references/examples.md +2 -2
- package/gangtise-openapi/references/fields.md +9 -7
- package/gangtise-openapi/references/response-schema.md +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "../core/asyncContent.js";
|
|
3
|
+
import { collectList, numberListArg, collectText, dateArg, datetimeArg, maybeArray, parseFrom, parseNumberOption, parseSize, parseTimestamp13 } from "../core/args.js";
|
|
4
|
+
import { ValidationError } from "../core/errors.js";
|
|
5
|
+
import { markFailed } from "../core/exitStatus.js";
|
|
6
|
+
import { parseOutputFormat } from "../core/output.js";
|
|
7
|
+
import { printData } from "../core/printer.js";
|
|
8
|
+
import { emit, withClient, runDownload, field, required, date, list, flag, format, output, from, size, requestBody, query } from "./shared.js";
|
|
9
|
+
import { checkMarketKeywords } from "./quote.js";
|
|
10
|
+
export const ai = new Command("ai").description("AI APIs");
|
|
11
|
+
function addGenerationTask(name, spec) {
|
|
12
|
+
const command = ai.command(name);
|
|
13
|
+
for (const f of [...spec.fields, flag("--wait", "Wait for content generation (blocking, up to ~5 min)"), format("json"), output()])
|
|
14
|
+
command.addOption(f.option);
|
|
15
|
+
command.action((options) => withClient(async (client) => {
|
|
16
|
+
const format = parseOutputFormat(options.format);
|
|
17
|
+
const idResult = await client.call(spec.submit, requestBody(spec.fields, options));
|
|
18
|
+
const dataId = idResult?.dataId;
|
|
19
|
+
if (!dataId) {
|
|
20
|
+
process.stderr.write(`${spec.idFailure}\n`);
|
|
21
|
+
markFailed();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!options.wait) {
|
|
25
|
+
process.stderr.write(`${spec.label} task submitted. dataId: ${dataId}\n`);
|
|
26
|
+
await printData({ dataId, status: "pending", hint: `Run 'gangtise ai ${name}-check --data-id ${dataId}' in ~2 minutes to get results` }, format, options.output);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
30
|
+
const outcome = await pollAsyncContent(client, spec.content, dataId, format, options.output);
|
|
31
|
+
if (outcome !== "ok") {
|
|
32
|
+
if (outcome === "timeout") {
|
|
33
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai ${name}-check --data-id ${dataId}\n`);
|
|
34
|
+
}
|
|
35
|
+
markFailed();
|
|
36
|
+
}
|
|
37
|
+
}));
|
|
38
|
+
ai.command(`${name}-check`).requiredOption("--data-id <id>", `dataId from ${name}`).option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient((client) => checkAsyncContent(client, spec.content, options.dataId, parseOutputFormat(options.format), options.output)));
|
|
39
|
+
}
|
|
40
|
+
ai.command("knowledge-batch")
|
|
41
|
+
.option("--query <text>", "Query text; repeat for up to 5. Each --query is taken whole — commas inside it are part of the question, not separators", collectText, [])
|
|
42
|
+
.option("--top <number>", "Max results (default: 10, max: 20)", "10")
|
|
43
|
+
.option("--resource-type <number>", "Resource type", numberListArg("--resource-type"), [])
|
|
44
|
+
.option("--knowledge-name <name>", "Knowledge name", collectList, [])
|
|
45
|
+
.option("--start-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)")
|
|
46
|
+
.option("--end-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)")
|
|
47
|
+
.option("--format <format>", "Output format", "json")
|
|
48
|
+
.option("--output <path>")
|
|
49
|
+
.action((options) => {
|
|
50
|
+
if (!options.query.length)
|
|
51
|
+
throw new ValidationError("--query is required: pass at least one --query");
|
|
52
|
+
return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time") }));
|
|
53
|
+
});
|
|
54
|
+
ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
|
|
55
|
+
await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
|
|
56
|
+
output: options.output,
|
|
57
|
+
fallbackName: `resource-${options.sourceId}`,
|
|
58
|
+
});
|
|
59
|
+
}));
|
|
60
|
+
query(ai, "security-clue", {
|
|
61
|
+
endpoint: "ai.security-clue.list",
|
|
62
|
+
fields: [
|
|
63
|
+
from(), size(),
|
|
64
|
+
field(new Option("--start-time <datetime>", "Start time").argParser(datetimeArg("--start-time")).makeOptionMandatory(), (v) => ({ startTime: v })),
|
|
65
|
+
field(new Option("--end-time <datetime>", "End time").argParser(datetimeArg("--end-time")).makeOptionMandatory(), (v) => ({ endTime: v })),
|
|
66
|
+
field(new Option("--query-mode <mode>").choices(["bySecurity", "byIndustry"]).makeOptionMandatory(), (v) => ({ queryMode: v })),
|
|
67
|
+
list("--gts-code <code>", "GTS code", "gtsCodeList"),
|
|
68
|
+
list("--source <name>", "Source", "source"),
|
|
69
|
+
format(), output(),
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
query(ai, "one-pager", { endpoint: "ai.one-pager", fields: [required("--security-code <code>", undefined, "securityCode"), format("json"), output()] });
|
|
73
|
+
query(ai, "investment-logic", { endpoint: "ai.investment-logic", fields: [required("--security-code <code>", undefined, "securityCode"), format("json"), output()] });
|
|
74
|
+
query(ai, "peer-comparison", { endpoint: "ai.peer-comparison", fields: [required("--security-code <code>", undefined, "securityCode"), format("json"), output()] });
|
|
75
|
+
addGenerationTask("earnings-review", {
|
|
76
|
+
submit: "ai.earnings-review.get-id",
|
|
77
|
+
content: "ai.earnings-review.get-content",
|
|
78
|
+
label: "Earnings review",
|
|
79
|
+
idFailure: "Failed to get earnings review ID. The report may not be available yet.",
|
|
80
|
+
fields: [required("--security-code <code>", undefined, "securityCode"), required("--period <period>", "Report period (e.g. 2025q3, 2025interim, 2025annual)", "period")],
|
|
81
|
+
});
|
|
82
|
+
query(ai, "theme-tracking", {
|
|
83
|
+
endpoint: "ai.theme-tracking",
|
|
84
|
+
fields: [
|
|
85
|
+
required("--theme-id <id>", "Theme ID (use 'reference concept-search')", "themeId"),
|
|
86
|
+
date("--date <date>", "Date (yyyy-MM-dd)", "date", { required: true }),
|
|
87
|
+
list("--type <name>", "Report type: morning/night", "type"),
|
|
88
|
+
format("json"), output(),
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
query(ai, "research-outline", { endpoint: "ai.research-outline", fields: [required("--security-code <code>", undefined, "securityCode"), format("json"), output()] });
|
|
92
|
+
ai.command("hot-topic")
|
|
93
|
+
.option("--from <number>", "Starting offset", "0")
|
|
94
|
+
.option("--size <number>", "Total rows to return; omit to fetch all")
|
|
95
|
+
.option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date"))
|
|
96
|
+
.option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date"))
|
|
97
|
+
.option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, [])
|
|
98
|
+
.option("--with-related-securities", "Include related securities info")
|
|
99
|
+
.option("--no-with-related-securities", "Exclude related securities info")
|
|
100
|
+
.option("--with-close-reading", "Include close reading content")
|
|
101
|
+
.option("--no-with-close-reading", "Exclude close reading content")
|
|
102
|
+
.option("--format <format>", "Output format", "json")
|
|
103
|
+
.option("--output <path>")
|
|
104
|
+
.action((options) => emit(options, (client) => {
|
|
105
|
+
const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
|
|
106
|
+
return client.call("ai.hot-topic", {
|
|
107
|
+
from: parseFrom(options.from),
|
|
108
|
+
size: parseSize(options.size),
|
|
109
|
+
startDate: options.startDate,
|
|
110
|
+
endDate: options.endDate,
|
|
111
|
+
categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
|
|
112
|
+
withRelatedSecurities: options.withRelatedSecurities !== false,
|
|
113
|
+
withCloseReading: options.withCloseReading !== false,
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
116
|
+
query(ai, "management-discuss-announcement", {
|
|
117
|
+
endpoint: "ai.management-discuss-announcement",
|
|
118
|
+
fields: [
|
|
119
|
+
date("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)", "reportDate", { required: true }),
|
|
120
|
+
required("--security-code <code>", "Security code (e.g. 000001.SZ)", "securityCode"),
|
|
121
|
+
field(new Option("--dimension <name>", "Discussion dimension: businessOperation/financialPerformance/developmentAndRisk/all").choices(["businessOperation", "financialPerformance", "developmentAndRisk", "all"]).makeOptionMandatory(), (v) => ({ discussionDimension: v })),
|
|
122
|
+
format("json"), output(),
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
query(ai, "management-discuss-earnings-call", {
|
|
126
|
+
endpoint: "ai.management-discuss-earnings-call",
|
|
127
|
+
fields: [
|
|
128
|
+
date("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)", "reportDate", { required: true }),
|
|
129
|
+
required("--security-code <code>", "Security code (e.g. 000001.SZ)", "securityCode"),
|
|
130
|
+
field(new Option("--dimension <name>", "Discussion dimension").choices(["businessOperation", "financialPerformance", "developmentAndRisk"]).makeOptionMandatory(), (v) => ({ discussionDimension: v })),
|
|
131
|
+
format("json"), output(),
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
addGenerationTask("viewpoint-debate", {
|
|
135
|
+
submit: "ai.viewpoint-debate.get-id",
|
|
136
|
+
content: "ai.viewpoint-debate.get-content",
|
|
137
|
+
label: "Viewpoint debate",
|
|
138
|
+
idFailure: "Failed to get viewpoint debate ID.",
|
|
139
|
+
fields: [required("--viewpoint <text>", "Viewpoint text (max 1000 chars)", "viewpoint")],
|
|
140
|
+
});
|
|
141
|
+
const STOCK_SUMMARY_MAX_SECURITIES = 6000;
|
|
142
|
+
ai.command("stock-summary")
|
|
143
|
+
.description("Stock highlights: refined research summary per security (A-share / HK)")
|
|
144
|
+
.option("--security <code>", `Security code (e.g. 600519.SH / 00700.HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call; market keywords are NOT supported by this endpoint`, collectList, [])
|
|
145
|
+
.option("--format <format>", "Output format", "table")
|
|
146
|
+
.option("--output <path>")
|
|
147
|
+
.action((options) => {
|
|
148
|
+
if (!options.security.length)
|
|
149
|
+
throw new ValidationError(`--security is required: pass one or more security codes (A-share / HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call`);
|
|
150
|
+
if (options.security.length > STOCK_SUMMARY_MAX_SECURITIES) {
|
|
151
|
+
throw new ValidationError(`ai stock-summary: ${options.security.length} securities in one call — the endpoint takes at most ${STOCK_SUMMARY_MAX_SECURITIES}. Split the codes into batches of at most ${STOCK_SUMMARY_MAX_SECURITIES} and run one call per batch.`);
|
|
152
|
+
}
|
|
153
|
+
checkMarketKeywords(options.security, [], "ai stock-summary");
|
|
154
|
+
return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
|
|
155
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { collectList, dateArg, parseNumberOption } from "../core/args.js";
|
|
3
|
+
import { assertColumnarHeader, zipFieldRow } from "../core/normalize.js";
|
|
4
|
+
import { parseOutputFormat } from "../core/output.js";
|
|
5
|
+
import { printData } from "../core/printer.js";
|
|
6
|
+
import { withClient, field, required, flag, format, output, query } from "./shared.js";
|
|
7
|
+
export const alternative = new Command("alternative").description("Alternative data APIs");
|
|
8
|
+
query(alternative, "edb-search", {
|
|
9
|
+
endpoint: "alternative.edb-search",
|
|
10
|
+
fields: [
|
|
11
|
+
required("--keyword <text>", "Search keyword (e.g. '空调')", "keyword"),
|
|
12
|
+
field(new Option("--limit <number>", "Max results (default: 100, max: 200)").default("100"), (v) => ({ limit: parseNumberOption(v, "--limit", { integer: true, min: 1, max: 200 }) })),
|
|
13
|
+
format(), output(),
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
alternative.command("edb-data")
|
|
17
|
+
.option("--indicator-id <id>", "Indicator ID (repeat, max 10)", collectList, [])
|
|
18
|
+
.requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date"))
|
|
19
|
+
.requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date"))
|
|
20
|
+
.option("--format <format>", "Output format", "table")
|
|
21
|
+
.option("--output <path>")
|
|
22
|
+
.action((options) => withClient(async (client) => {
|
|
23
|
+
const format = parseOutputFormat(options.format);
|
|
24
|
+
const raw = await client.call("alternative.edb-data", {
|
|
25
|
+
indicatorIdList: options.indicatorId,
|
|
26
|
+
startDate: options.startDate,
|
|
27
|
+
endDate: options.endDate,
|
|
28
|
+
});
|
|
29
|
+
let data = raw;
|
|
30
|
+
if (raw && Array.isArray(raw.fieldList) && Array.isArray(raw.dataList)) {
|
|
31
|
+
const fields = raw.fieldList;
|
|
32
|
+
assertColumnarHeader(fields, raw);
|
|
33
|
+
const list = raw.dataList.map((row) => zipFieldRow(fields, row, raw));
|
|
34
|
+
data = { list, total: list.length };
|
|
35
|
+
}
|
|
36
|
+
await printData(data, format, options.output);
|
|
37
|
+
}));
|
|
38
|
+
const CONCEPT_ID = "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'";
|
|
39
|
+
query(alternative, "concept-info", {
|
|
40
|
+
description: "Concept profile: definition, investment logic, industry space, competitive landscape (50 credits/call)",
|
|
41
|
+
endpoint: (options) => options.full ? "alternative.concept-info-full" : "alternative.concept-info",
|
|
42
|
+
fields: [
|
|
43
|
+
required("--concept-id <id>", CONCEPT_ID, "conceptId"),
|
|
44
|
+
flag("--full", "Also return catalyst events (keyEvents) via the v1 endpoint — 500 credits/call instead of 50"),
|
|
45
|
+
format("json"), output(),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
query(alternative, "concept-securities", {
|
|
49
|
+
description: "Concept constituents, grouped (50 credits/call; free when the concept has none)",
|
|
50
|
+
endpoint: (options) => options.full ? "alternative.concept-securities-full" : "alternative.concept-securities",
|
|
51
|
+
fields: [
|
|
52
|
+
required("--concept-id <id>", CONCEPT_ID, "conceptId"),
|
|
53
|
+
flag("--full", "Also return the key-stock flag (isKey) and inclusion reason via the v1 endpoint — 500 credits/call instead of 50"),
|
|
54
|
+
format("json"), output(),
|
|
55
|
+
],
|
|
56
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readTokenCache, redactTokenCache } from "../core/auth.js";
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { parseOutputFormat } from "../core/output.js";
|
|
5
|
+
import { printData } from "../core/printer.js";
|
|
6
|
+
import { emit } from "./shared.js";
|
|
7
|
+
export const auth = new Command("auth")
|
|
8
|
+
.description("Authentication commands")
|
|
9
|
+
.addCommand(new Command("login")
|
|
10
|
+
.option("--show-token", "Show the raw access token (default: redacted)")
|
|
11
|
+
.option("--format <format>", "Output format", "json")
|
|
12
|
+
.action((options) => emit(options, async (client) => {
|
|
13
|
+
const result = await client.login();
|
|
14
|
+
const note = result.source === "env-token"
|
|
15
|
+
? "GANGTISE_TOKEN is set, so this is the token every command will send — no login was performed. Unset it to use GANGTISE_ACCESS_KEY / GANGTISE_SECRET_KEY instead."
|
|
16
|
+
: undefined;
|
|
17
|
+
const base = { source: result.source, ...(note ? { note } : {}) };
|
|
18
|
+
return options.showToken
|
|
19
|
+
? { ...base, authorization: result.authorization, cache: result.cache }
|
|
20
|
+
: { ...base, authorization: "<redacted>", cache: redactTokenCache(result.cache) };
|
|
21
|
+
})))
|
|
22
|
+
.addCommand(new Command("status")
|
|
23
|
+
.option("--format <format>", "Output format", "json")
|
|
24
|
+
.action(async (options) => {
|
|
25
|
+
const config = loadConfig();
|
|
26
|
+
const cache = await readTokenCache(config.tokenCachePath);
|
|
27
|
+
await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache: redactTokenCache(cache) }, parseOutputFormat(options.format));
|
|
28
|
+
}));
|
|
29
|
+
export const lookup = new Command("lookup").description("Local lookup tables (IDs not covered by 'reference constant-list')");
|
|
30
|
+
const addLookupList = (name, endpointKey, description) => {
|
|
31
|
+
const cmd = new Command(name);
|
|
32
|
+
if (description)
|
|
33
|
+
cmd.description(description);
|
|
34
|
+
lookup.addCommand(cmd.addCommand(new Command("list").option("--format <format>", "Output format", "table").action((options) => emit(options, (client) => client.call(endpointKey)))));
|
|
35
|
+
};
|
|
36
|
+
addLookupList("broker-org", "lookup.broker-orgs.list");
|
|
37
|
+
addLookupList("meeting-org", "lookup.meeting-orgs.list");
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { collectList, dateArg, maybeArray, parseNumberOption } from "../core/args.js";
|
|
3
|
+
import { ValidationError } from "../core/errors.js";
|
|
4
|
+
import { emit } from "./shared.js";
|
|
5
|
+
export const bond = new Command("bond").description("Bond APIs: profiles, issuers, quotes, valuations, cash flows, ratings, announcements");
|
|
6
|
+
function requireBondCodes(codes, max) {
|
|
7
|
+
if (!codes.length)
|
|
8
|
+
throw new ValidationError("--security is required: pass one or more bond codes, e.g. --security 019742.SH --security 220205.IB");
|
|
9
|
+
const unique = [...new Set(codes)];
|
|
10
|
+
if (max && unique.length > max)
|
|
11
|
+
throw new ValidationError(`${unique.length} distinct bond codes in one call — this endpoint takes at most ${max}. Split them into batches of ${max} and run one call per batch.`);
|
|
12
|
+
return unique;
|
|
13
|
+
}
|
|
14
|
+
const bondSecurityOption = (command) => command
|
|
15
|
+
.option("--security <code>", "Bond code, e.g. 019742.SH / 123456.SZ / 220205.IB (repeatable). Short names and pinyin are rejected — resolve them with 'reference securities-search' first", collectList, [])
|
|
16
|
+
.option("--field <field>", "Field to return (repeatable); omit for all. An unsupported name rejects the whole call with 100003", collectList, [])
|
|
17
|
+
.option("--format <format>", "Output format", "table").option("--output <path>");
|
|
18
|
+
const addBondRange = (name, endpointKey, describe, rangeHelp, max) => bondSecurityOption(bond.command(name).description(describe))
|
|
19
|
+
.option("--start-date <date>", `${rangeHelp} start (yyyy-MM-dd); omit for all history`, dateArg("--start-date"))
|
|
20
|
+
.option("--end-date <date>", `${rangeHelp} end (yyyy-MM-dd); omit for all history`, dateArg("--end-date"))
|
|
21
|
+
.action((options) => emit(options, (client) => client.call(endpointKey, { securityList: requireBondCodes(options.security, max), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
|
|
22
|
+
bondSecurityOption(bond.command("basic-info").description("Bond static profiles: issuance, term, coupon, rating, guarantee, special terms"))
|
|
23
|
+
.action((options) => emit(options, (client) => client.call("bond.basic-info", { securityList: requireBondCodes(options.security, 10000), fieldList: maybeArray(options.field) })));
|
|
24
|
+
bondSecurityOption(bond.command("rating-overview").description("Bond, issuer and guarantor ratings side by side"))
|
|
25
|
+
.action((options) => emit(options, (client) => client.call("bond.rating-overview", { securityList: requireBondCodes(options.security, 10), fieldList: maybeArray(options.field) })));
|
|
26
|
+
addBondRange("cash-flow", "bond.cash-flow", "Interest payment and redemption schedule per bond", "Payment date");
|
|
27
|
+
addBondRange("issuance-detail", "bond.issuance-detail", "Issuance and re-issuance records: bidding, pricing, cover ratios", "Issue announcement date");
|
|
28
|
+
addBondRange("rating-change", "bond.rating-change", "Bond rating change history: current vs previous rating, direction, outlook, agency (max 10 bonds per call)", "Announcement date", 10);
|
|
29
|
+
addBondRange("exercise-notice", "bond.exercise-notice", "Put/call exercise schedule and results for option-embedded bonds", "Exercise date");
|
|
30
|
+
bondSecurityOption(bond.command("daily-quote").description("Daily close quotes (exchange + CFETS): dirty/clean price, YTM, duration, convexity"))
|
|
31
|
+
.requiredOption("--start-date <date>", "Trade date range start (yyyy-MM-dd)", dateArg("--start-date"))
|
|
32
|
+
.requiredOption("--end-date <date>", "Trade date range end (yyyy-MM-dd)", dateArg("--end-date"))
|
|
33
|
+
.action((options) => emit(options, (client) => client.call("bond.daily-quote", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
|
|
34
|
+
bondSecurityOption(bond.command("valuation").description("Shanghai Clearing House valuations: price, yield, duration, convexity, PVBP"))
|
|
35
|
+
.requiredOption("--start-date <date>", "Valuation date range start (yyyy-MM-dd)", dateArg("--start-date"))
|
|
36
|
+
.requiredOption("--end-date <date>", "Valuation date range end (yyyy-MM-dd)", dateArg("--end-date"))
|
|
37
|
+
.addOption(new Option("--confidence-level <level>", "Valuation confidence; defaults to 推荐 server-side, which already filters out 不推荐").choices(["推荐", "不推荐"]))
|
|
38
|
+
.action((options) => emit(options, (client) => client.call("bond.valuation", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, confidenceLevel: options.confidenceLevel, fieldList: maybeArray(options.field) })));
|
|
39
|
+
function issuerSelector(security, issuer) {
|
|
40
|
+
if (security.length && issuer.length)
|
|
41
|
+
throw new ValidationError("--security and --issuer are mutually exclusive: pass bond codes or issuer names, not both");
|
|
42
|
+
if (!security.length && !issuer.length)
|
|
43
|
+
throw new ValidationError("pass either --security (bond codes) or --issuer (issuer names)");
|
|
44
|
+
return security.length ? { securityList: security } : { issuerNameList: issuer };
|
|
45
|
+
}
|
|
46
|
+
const issuerNameOption = (command) => command.option("--issuer <name>", "Issuer name, full or short (repeatable); fuzzy-matched, one best hit per name", collectList, []);
|
|
47
|
+
issuerNameOption(bondSecurityOption(bond.command("issuer-info").description("Issuer profiles: nature, SW industry, registration, rating, outstanding bonds")))
|
|
48
|
+
.action((options) => emit(options, (client) => client.call("bond.issuer-info", { ...issuerSelector(options.security, options.issuer), fieldList: maybeArray(options.field) })));
|
|
49
|
+
issuerNameOption(bondSecurityOption(bond.command("issuer-rating-change").description("Issuer rating change history (at most 10 issuers matched per call)")))
|
|
50
|
+
.option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); omit for all history", dateArg("--start-date"))
|
|
51
|
+
.option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); omit for all history", dateArg("--end-date"))
|
|
52
|
+
.action((options) => emit(options, (client) => client.call("bond.issuer-rating-change", { ...issuerSelector(options.security, options.issuer), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
|
|
53
|
+
bond.command("issuance-plan").description("Rate-bond issuance calendar over a date range")
|
|
54
|
+
.requiredOption("--start-date <date>", "Issue date range start (yyyy-MM-dd)", dateArg("--start-date"))
|
|
55
|
+
.requiredOption("--end-date <date>", "Issue date range end (yyyy-MM-dd)", dateArg("--end-date"))
|
|
56
|
+
.option("--field <field>", "Field to return (repeatable); omit for all", collectList, [])
|
|
57
|
+
.option("--format <format>", "Output format", "table").option("--output <path>")
|
|
58
|
+
.action((options) => emit(options, (client) => client.call("bond.issuance-plan", { startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
|
|
59
|
+
bondSecurityOption(bond.command("announcement").description("Bond announcements, paged. Filter by bond codes OR by announcement date range, never both"))
|
|
60
|
+
.option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); mutually exclusive with --security", dateArg("--start-date"))
|
|
61
|
+
.option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); mutually exclusive with --security", dateArg("--end-date"))
|
|
62
|
+
.option("--page-no <number>", "Page number, from 1. No total is returned: increment until a page comes back empty", "1")
|
|
63
|
+
.option("--page-size <number>", "Rows per page, 1-200", "50")
|
|
64
|
+
.action((options) => emit(options, (client) => {
|
|
65
|
+
const byDate = Boolean(options.startDate || options.endDate);
|
|
66
|
+
if (options.security.length && byDate)
|
|
67
|
+
throw new ValidationError("--security and --start-date/--end-date are mutually exclusive on bond announcement: filter by bond codes or by date range, not both");
|
|
68
|
+
if (!options.security.length && !byDate)
|
|
69
|
+
throw new ValidationError("pass either --security (bond codes) or --start-date/--end-date (announcement date range)");
|
|
70
|
+
return client.call("bond.announcement", {
|
|
71
|
+
securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate,
|
|
72
|
+
pageNo: parseNumberOption(options.pageNo, "--page-no", { integer: true, min: 1 }),
|
|
73
|
+
pageSize: parseNumberOption(options.pageSize, "--page-size", { integer: true, min: 1, max: 200 }),
|
|
74
|
+
fieldList: maybeArray(options.field),
|
|
75
|
+
});
|
|
76
|
+
}));
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { collectList, dateArg, localDateString, maybeArray, parseOptionalNumberOption } from "../core/args.js";
|
|
3
|
+
import { normalizeRows } from "../core/normalize.js";
|
|
4
|
+
import { parseOutputFormat } from "../core/output.js";
|
|
5
|
+
import { printData } from "../core/printer.js";
|
|
6
|
+
import { emit, withClient, flagIfLimitTruncated, field, required, date, list, format, output, query } from "./shared.js";
|
|
7
|
+
export const fundamental = new Command("fundamental").description("Fundamental APIs");
|
|
8
|
+
const addFinancialReport = (name, endpointKey, periodHelp = "Period") => query(fundamental, name, {
|
|
9
|
+
endpoint: endpointKey,
|
|
10
|
+
fields: [
|
|
11
|
+
required("--security-code <code>", undefined, "securityCode"),
|
|
12
|
+
date("--start-date <date>", "Start date (yyyy-MM-dd)", "startDate"),
|
|
13
|
+
date("--end-date <date>", "End date (yyyy-MM-dd)", "endDate"),
|
|
14
|
+
list("--fiscal-year <year>", "Fiscal year", "fiscalYear"),
|
|
15
|
+
list("--period <period>", periodHelp, "period"),
|
|
16
|
+
list("--report-type <type>", "Report type", "reportType"),
|
|
17
|
+
list("--field <field>", "Field", "fieldList"),
|
|
18
|
+
format(), output(),
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
addFinancialReport("income-statement", "fundamental.income-statement");
|
|
22
|
+
addFinancialReport("income-statement-quarterly", "fundamental.income-statement-quarterly", "Period: q1/q2/q3/q4/latest");
|
|
23
|
+
addFinancialReport("balance-sheet", "fundamental.balance-sheet");
|
|
24
|
+
addFinancialReport("cash-flow", "fundamental.cash-flow");
|
|
25
|
+
addFinancialReport("cash-flow-quarterly", "fundamental.cash-flow-quarterly", "Period: q1/q2/q3/q4/latest");
|
|
26
|
+
addFinancialReport("income-statement-hk", "fundamental.income-statement-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
|
|
27
|
+
addFinancialReport("balance-sheet-hk", "fundamental.balance-sheet-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
|
|
28
|
+
addFinancialReport("cash-flow-hk", "fundamental.cash-flow-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
|
|
29
|
+
addFinancialReport("income-statement-us", "fundamental.income-statement-us", "Period: q1/h1/q3/nsd/annual/latest");
|
|
30
|
+
addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period: q1/h1/q3/nsd/annual/latest");
|
|
31
|
+
addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
|
|
32
|
+
query(fundamental, "main-business", {
|
|
33
|
+
endpoint: "fundamental.main-business",
|
|
34
|
+
fields: [
|
|
35
|
+
required("--security-code <code>", undefined, "securityCode"),
|
|
36
|
+
date("--start-date <date>", "Start date (yyyy-MM-dd)", "startDate"),
|
|
37
|
+
date("--end-date <date>", "End date (yyyy-MM-dd)", "endDate"),
|
|
38
|
+
field(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product"), (v) => ({ breakdown: v })),
|
|
39
|
+
list("--period <type>", "Period: interim/annual", "periodList"),
|
|
40
|
+
list("--field <field>", "Field", "fieldList"),
|
|
41
|
+
format(), output(),
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
const VALUATION_DEFAULT_LIMIT = 2000;
|
|
45
|
+
function valuationFieldsToSend(fields) {
|
|
46
|
+
const unique = [...new Set(fields)];
|
|
47
|
+
const values = unique.filter((field) => field !== "tradeDate");
|
|
48
|
+
return values.length > 0 ? values : unique;
|
|
49
|
+
}
|
|
50
|
+
const VALUATION_VALUE_COLUMNS = ["value", "percentileRank", "average", "median", "upper1Std", "lower1Std"];
|
|
51
|
+
function rowsIn(data) {
|
|
52
|
+
const normalized = normalizeRows(data);
|
|
53
|
+
const list = Array.isArray(normalized) ? normalized : normalized?.list;
|
|
54
|
+
return Array.isArray(list) ? list.length : 0;
|
|
55
|
+
}
|
|
56
|
+
function firstRowTradeDate(data) {
|
|
57
|
+
const normalized = normalizeRows(data);
|
|
58
|
+
const list = Array.isArray(normalized) ? normalized : normalized?.list;
|
|
59
|
+
const first = Array.isArray(list) ? list[0] : undefined;
|
|
60
|
+
const value = first && typeof first === "object" ? first.tradeDate : undefined;
|
|
61
|
+
return typeof value === "string" ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
fundamental.command("valuation-analysis")
|
|
64
|
+
.requiredOption("--security-code <code>")
|
|
65
|
+
.addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).makeOptionMandatory())
|
|
66
|
+
.option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date"))
|
|
67
|
+
.option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date"))
|
|
68
|
+
.option("--limit <number>", "Max rows (default: 2000). One row per calendar day, weekends included, and the most recent are kept — a range longer than the limit loses its START (flagged partial, exit 3)")
|
|
69
|
+
.option("--field <field>", "Field", collectList, [])
|
|
70
|
+
.option("--skip-null", "Drop rows where value or percentileRank is null")
|
|
71
|
+
.option("--format <format>", "Output format", "table")
|
|
72
|
+
.option("--output <path>")
|
|
73
|
+
.action((options) => withClient(async (client) => {
|
|
74
|
+
const format = parseOutputFormat(options.format);
|
|
75
|
+
const requested = maybeArray(options.field);
|
|
76
|
+
const filterFields = options.skipNull && requested ? ["value", "percentileRank"].filter((field) => !requested.includes(field)) : [];
|
|
77
|
+
const fieldList = requested && valuationFieldsToSend([...requested, ...filterFields]);
|
|
78
|
+
const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }) ?? VALUATION_DEFAULT_LIMIT;
|
|
79
|
+
let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit, fieldList });
|
|
80
|
+
if (fieldList && !fieldList.some((field) => VALUATION_VALUE_COLUMNS.includes(field)) && rowsIn(data) === 0) {
|
|
81
|
+
process.stderr.write(`[gangtise] note: no rows came back, and --field names no value column (${fieldList.join(", ")}). This endpoint answers such a request with an empty list rather than an error, so it says nothing about whether data exists: add one of ${VALUATION_VALUE_COLUMNS.join(" / ")}, or drop --field.\n`);
|
|
82
|
+
}
|
|
83
|
+
const firstDate = firstRowTradeDate(data);
|
|
84
|
+
if (!(options.startDate && firstDate === options.startDate)) {
|
|
85
|
+
flagIfLimitTruncated(data, limit, "fundamental valuation-analysis", "--start-date", `The API keeps the most recent rows, so it is the START of the range that is missing. The series has one row per calendar day (weekends included): raise --limit to at least the number of days in the range (e.g. --limit 4000 for ten years within your account's history window), or move --start-date later.`);
|
|
86
|
+
}
|
|
87
|
+
if (options.startDate && firstDate && firstDate > options.startDate && !data.partial) {
|
|
88
|
+
process.stderr.write(`[gangtise] note: the series starts at ${firstDate}, later than --start-date ${options.startDate}. Either the security listed later, or the range reaches past your account's history window — this endpoint returns what lies inside the window without an error.\n`);
|
|
89
|
+
}
|
|
90
|
+
if (options.skipNull) {
|
|
91
|
+
const normalized = normalizeRows(data);
|
|
92
|
+
const rec = (Array.isArray(normalized) ? { list: normalized } : normalized);
|
|
93
|
+
if (rec && typeof rec === "object") {
|
|
94
|
+
if (Array.isArray(rec.list)) {
|
|
95
|
+
const filtered = rec.list.filter((row) => {
|
|
96
|
+
if (!row || typeof row !== "object")
|
|
97
|
+
return false;
|
|
98
|
+
const r = row;
|
|
99
|
+
return r.value != null && r.percentileRank != null;
|
|
100
|
+
}).map((row) => {
|
|
101
|
+
if (filterFields.length === 0)
|
|
102
|
+
return row;
|
|
103
|
+
const r = { ...row };
|
|
104
|
+
for (const field of filterFields)
|
|
105
|
+
delete r[field];
|
|
106
|
+
return r;
|
|
107
|
+
});
|
|
108
|
+
data = { ...rec, list: filtered, total: filtered.length };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
await printData(data, format, options.output);
|
|
113
|
+
}));
|
|
114
|
+
query(fundamental, "top-holders", {
|
|
115
|
+
endpoint: "fundamental.top-holders",
|
|
116
|
+
fields: [
|
|
117
|
+
required("--security-code <code>", undefined, "securityCode"),
|
|
118
|
+
field(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory(), (v) => ({ holderType: v })),
|
|
119
|
+
date("--start-date <date>", "Start date (yyyy-MM-dd)", "startDate"),
|
|
120
|
+
date("--end-date <date>", "End date (yyyy-MM-dd)", "endDate"),
|
|
121
|
+
list("--fiscal-year <year>", "Fiscal year", "fiscalYear"),
|
|
122
|
+
list("--period <period>", "Period: q1/interim/q3/annual/latest", "period"),
|
|
123
|
+
format(), output(),
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
fundamental.command("earning-forecast")
|
|
127
|
+
.requiredOption("--security-code <code>")
|
|
128
|
+
.option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date"))
|
|
129
|
+
.option("--end-date <date>", "End date (default: today)", dateArg("--end-date"))
|
|
130
|
+
.option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, [])
|
|
131
|
+
.option("--format <format>", "Output format", "table")
|
|
132
|
+
.option("--output <path>")
|
|
133
|
+
.action((options) => emit(options, (client) => {
|
|
134
|
+
const endDate = options.endDate ?? localDateString(new Date());
|
|
135
|
+
const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
136
|
+
return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
|
|
137
|
+
}));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { collectList, dateArg, screenerExpressionFields, parseNumberOption, parseScreenerIndicators } from "../core/args.js";
|
|
3
|
+
import { buildIndicatorCrossSectionBody, buildIndicatorScreenerBody, buildIndicatorTimeSeriesBody } from "../core/commandBodies.js";
|
|
4
|
+
import { checkScreenerBindings, droppedFromMatrix, flattenCrossSection, flattenTimeSeries, isEmptyMatrix, requireIndicatorMatrix, unwrapIndicatorData } from "../core/indicatorMatrix.js";
|
|
5
|
+
import { resolveCalendarType } from "../core/calendarType.js";
|
|
6
|
+
import { ValidationError } from "../core/errors.js";
|
|
7
|
+
import { parseOutputFormat } from "../core/output.js";
|
|
8
|
+
import { printData } from "../core/printer.js";
|
|
9
|
+
import { withClient } from "./shared.js";
|
|
10
|
+
function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
|
|
11
|
+
if (isEmptyMatrix(data)) {
|
|
12
|
+
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");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const { securities, indicators } = droppedFromMatrix(data, requestedSecurities, requestedIndicators);
|
|
16
|
+
if (securities.length === 0 && indicators.length === 0)
|
|
17
|
+
return;
|
|
18
|
+
if (rows && typeof rows === "object" && !Array.isArray(rows)) {
|
|
19
|
+
const rec = rows;
|
|
20
|
+
rec.partial = true;
|
|
21
|
+
if (indicators.length > 0)
|
|
22
|
+
rec.omittedIndicators = indicators;
|
|
23
|
+
if (securities.length > 0)
|
|
24
|
+
rec.omittedSecurities = securities;
|
|
25
|
+
}
|
|
26
|
+
const parts = [];
|
|
27
|
+
if (indicators.length > 0)
|
|
28
|
+
parts.push(`indicators ${indicators.join(", ")}`);
|
|
29
|
+
if (securities.length > 0)
|
|
30
|
+
parts.push(`securities ${securities.join(", ")}`);
|
|
31
|
+
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`);
|
|
32
|
+
}
|
|
33
|
+
function requireIndicatorScope(indicators, securities) {
|
|
34
|
+
const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
throw new ValidationError(`${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} required (repeat the flag for multiple values)`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series, screener");
|
|
40
|
+
indicator.command("search")
|
|
41
|
+
.requiredOption("--keyword <text>", "Search keyword, e.g. '收盘价' '成交量' '营业收入' (not free-form questions)")
|
|
42
|
+
.option("--limit <number>", "Max results (default: 50, max: 100)", "50")
|
|
43
|
+
.option("--format <format>", "Output format", "table")
|
|
44
|
+
.option("--output <path>")
|
|
45
|
+
.action((options) => withClient(async (client) => {
|
|
46
|
+
const format = parseOutputFormat(options.format);
|
|
47
|
+
const raw = await client.call("indicator.search", {
|
|
48
|
+
keyword: options.keyword,
|
|
49
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
|
|
50
|
+
});
|
|
51
|
+
await printData(unwrapIndicatorData(raw), format, options.output);
|
|
52
|
+
}));
|
|
53
|
+
indicator.command("cross-section")
|
|
54
|
+
.option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, [])
|
|
55
|
+
.option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, [])
|
|
56
|
+
.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"))
|
|
57
|
+
.option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)")
|
|
58
|
+
.option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)")
|
|
59
|
+
.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, [])
|
|
60
|
+
.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"))
|
|
61
|
+
.option("--format <format>", "Output format", "table")
|
|
62
|
+
.option("--output <path>")
|
|
63
|
+
.action((options) => withClient(async (client) => {
|
|
64
|
+
const format = parseOutputFormat(options.format);
|
|
65
|
+
requireIndicatorScope(options.indicator, options.security);
|
|
66
|
+
const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
|
|
67
|
+
const data = requireIndicatorMatrix(raw);
|
|
68
|
+
const rows = flattenCrossSection(data, options.keyBy);
|
|
69
|
+
flagDropped(rows, data, options.security, options.indicator);
|
|
70
|
+
await printData(rows, format, options.output);
|
|
71
|
+
}));
|
|
72
|
+
indicator.command("time-series")
|
|
73
|
+
.option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, [])
|
|
74
|
+
.option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, [])
|
|
75
|
+
.requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date"))
|
|
76
|
+
.requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date"))
|
|
77
|
+
.option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday. Omit it and the CLI picks: a free 'indicator search' reads each parameterList and sends TD only when EVERY indicator is trading-day typed, otherwise it leaves the server on ND (report-period indicators land on dates a trading calendar lacks, and TD would answer them with an all-null grid). An explicit value is sent as given, with no lookup")
|
|
78
|
+
.option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)")
|
|
79
|
+
.option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)")
|
|
80
|
+
.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, [])
|
|
81
|
+
.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"))
|
|
82
|
+
.option("--format <format>", "Output format", "table")
|
|
83
|
+
.option("--output <path>")
|
|
84
|
+
.action((options) => withClient(async (client) => {
|
|
85
|
+
const format = parseOutputFormat(options.format);
|
|
86
|
+
requireIndicatorScope(options.indicator, options.security);
|
|
87
|
+
const body = buildIndicatorTimeSeriesBody(options);
|
|
88
|
+
if (!body.calendarType) {
|
|
89
|
+
const resolved = await resolveCalendarType(client, options.indicator);
|
|
90
|
+
if (resolved) {
|
|
91
|
+
body.calendarType = resolved;
|
|
92
|
+
process.stderr.write(`[gangtise] note: every requested indicator takes tradeDate, so this series was fetched on the trading-day calendar (calendarType=TD) instead of the server default ND — no all-null non-trading rows, and fewer cells against the 30000 cap. Pass --calendar-type ND to override.\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const raw = await client.call("indicator.time-series", body);
|
|
96
|
+
const data = requireIndicatorMatrix(raw);
|
|
97
|
+
const rows = flattenTimeSeries(data, options.keyBy, options.security);
|
|
98
|
+
flagDropped(rows, data, options.security, options.indicator);
|
|
99
|
+
await printData(rows, format, options.output);
|
|
100
|
+
}));
|
|
101
|
+
indicator.command("screener")
|
|
102
|
+
.description("Screen securities by an expression over indicator values (条件选股)")
|
|
103
|
+
.option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", collectList, [])
|
|
104
|
+
.option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, [])
|
|
105
|
+
.requiredOption("--expression <expr>", "Filter over the bound variables, e.g. 'F1 >= 800 && (F2 >= 20 && F2 <= 30)'; also supports contains/notcontains on string indicators")
|
|
106
|
+
.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"))
|
|
107
|
+
.option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'. Bare 'F1:' (nothing after the colon) declares that 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_sctr, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'F1:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'F1:' + 'F1:scale=8' keeps the scale", collectList, [])
|
|
108
|
+
.addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode").choices(["name", "code"]).default("name"))
|
|
109
|
+
.option("--format <format>", "Output format", "table")
|
|
110
|
+
.option("--output <path>")
|
|
111
|
+
.action((options) => withClient(async (client) => {
|
|
112
|
+
const format = parseOutputFormat(options.format);
|
|
113
|
+
requireIndicatorScope(options.indicator, options.security);
|
|
114
|
+
const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
|
|
115
|
+
const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
|
|
116
|
+
const data = requireIndicatorMatrix(raw);
|
|
117
|
+
const rows = flattenCrossSection(data, options.keyBy);
|
|
118
|
+
const filteredOn = screenerExpressionFields(options.expression);
|
|
119
|
+
const unbound = checkScreenerBindings(data, bindings, options.expression);
|
|
120
|
+
if (data.securityCodeList.length === 0) {
|
|
121
|
+
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");
|
|
122
|
+
}
|
|
123
|
+
if (unbound.length > 0) {
|
|
124
|
+
const rec = rows;
|
|
125
|
+
rec.partial = true;
|
|
126
|
+
rec.omittedIndicators = unbound.map((field) => bindings.find((b) => b.field === field)?.indicatorCode ?? field);
|
|
127
|
+
const alsoFiltered = unbound.filter((field) => filteredOn.includes(field));
|
|
128
|
+
const filterNote = alsoFiltered.length > 0
|
|
129
|
+
? ` 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.`
|
|
130
|
+
: "";
|
|
131
|
+
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`);
|
|
132
|
+
}
|
|
133
|
+
await printData(rows, format, options.output);
|
|
134
|
+
}));
|