gangtise-openapi-cli 0.28.3 → 0.30.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 +61 -181
- package/dist/src/cli.js +122 -7
- package/dist/src/core/args.js +56 -15
- package/dist/src/core/asyncContent.js +5 -3
- package/dist/src/core/client.js +39 -12
- package/dist/src/core/commandBodies.js +42 -6
- package/dist/src/core/endpoints.js +43 -0
- package/dist/src/core/fileParse.js +87 -0
- package/dist/src/core/indicatorMatrix.js +51 -25
- package/dist/src/core/transport.js +20 -0
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +18 -11
- package/gangtise-openapi/references/commands/indicator.md +79 -37
- package/gangtise-openapi/references/commands/insight.md +19 -0
- package/gangtise-openapi/references/commands/tool.md +40 -0
- package/gangtise-openapi/references/commands/vault.md +3 -1
- package/gangtise-openapi/references/examples.md +36 -2
- package/gangtise-openapi/references/response-schema.md +8 -4
- package/package.json +1 -1
package/dist/src/core/args.js
CHANGED
|
@@ -247,29 +247,70 @@ export function localDateString(d) {
|
|
|
247
247
|
const day = String(d.getDate()).padStart(2, "0");
|
|
248
248
|
return `${d.getFullYear()}-${month}-${day}`;
|
|
249
249
|
}
|
|
250
|
-
// Parse repeatable
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return undefined;
|
|
250
|
+
// Parse repeatable `"<lhs>:key=value"` specs into per-lhs parameter lists.
|
|
251
|
+
// The left-hand side is an indicator code for cross-section / time-series and a
|
|
252
|
+
// screener variable (F1, F2…) for the screener, so it stays an opaque string
|
|
253
|
+
// here. Multiple specs for the same lhs accumulate, first-seen order.
|
|
254
|
+
function parseParamSpecs(specs, option, syntax) {
|
|
256
255
|
const groups = new Map();
|
|
257
256
|
for (const spec of specs) {
|
|
258
257
|
const colon = spec.indexOf(":");
|
|
259
258
|
const rest = colon === -1 ? "" : spec.slice(colon + 1);
|
|
260
259
|
const eq = rest.indexOf("=");
|
|
261
|
-
const
|
|
260
|
+
const lhs = colon === -1 ? "" : spec.slice(0, colon).trim();
|
|
262
261
|
const paramKey = eq === -1 ? "" : rest.slice(0, eq).trim();
|
|
263
262
|
const paramValue = eq === -1 ? "" : rest.slice(eq + 1).trim();
|
|
264
|
-
if (!
|
|
265
|
-
throw new ValidationError(`Invalid
|
|
263
|
+
if (!lhs || !paramKey) {
|
|
264
|
+
throw new ValidationError(`Invalid ${option}: expected "${syntax}", got "${spec}"`);
|
|
266
265
|
}
|
|
267
|
-
let
|
|
268
|
-
if (!
|
|
269
|
-
|
|
270
|
-
groups.set(
|
|
266
|
+
let params = groups.get(lhs);
|
|
267
|
+
if (!params) {
|
|
268
|
+
params = [];
|
|
269
|
+
groups.set(lhs, params);
|
|
270
|
+
}
|
|
271
|
+
params.push({ paramKey, paramValue });
|
|
272
|
+
}
|
|
273
|
+
return groups;
|
|
274
|
+
}
|
|
275
|
+
// Parse repeatable `--indicator-param "code:key=value"` specs into the nested
|
|
276
|
+
// indicatorParamList the EDE cross-section / time-series endpoints expect.
|
|
277
|
+
export function parseIndicatorParams(specs) {
|
|
278
|
+
if (specs.length === 0)
|
|
279
|
+
return undefined;
|
|
280
|
+
return [...parseParamSpecs(specs, "--indicator-param", "code:key=value")].map(([indicatorCode, parameters]) => ({ indicatorCode, parameters }));
|
|
281
|
+
}
|
|
282
|
+
/** Screener variables are `F` + a positive integer; the server rejects anything
|
|
283
|
+
* else, and a typo'd variable would otherwise surface as an opaque expression
|
|
284
|
+
* error rather than pointing at the binding that is wrong. */
|
|
285
|
+
const SCREENER_FIELD = /^F[1-9][0-9]*$/;
|
|
286
|
+
// Parse the screener's `--indicator "F1:code"` bindings and merge each one's
|
|
287
|
+
// `--indicator-param "F1:key=value"` specs. Params key off the variable, not the
|
|
288
|
+
// code, because the same indicator may legitimately appear under two variables
|
|
289
|
+
// with different parameters (e.g. the same price on two dates).
|
|
290
|
+
export function parseScreenerIndicators(bindings, paramSpecs) {
|
|
291
|
+
const params = parseParamSpecs(paramSpecs, "--indicator-param", "F1:key=value");
|
|
292
|
+
const indicators = new Map();
|
|
293
|
+
for (const spec of bindings) {
|
|
294
|
+
const colon = spec.indexOf(":");
|
|
295
|
+
const field = colon === -1 ? "" : spec.slice(0, colon).trim();
|
|
296
|
+
const indicatorCode = colon === -1 ? "" : spec.slice(colon + 1).trim();
|
|
297
|
+
if (!field || !indicatorCode) {
|
|
298
|
+
throw new ValidationError(`Invalid --indicator: expected "F1:code", got "${spec}"`);
|
|
299
|
+
}
|
|
300
|
+
if (!SCREENER_FIELD.test(field)) {
|
|
301
|
+
throw new ValidationError(`Invalid --indicator variable "${field}": expected F followed by a positive integer, e.g. F1`);
|
|
302
|
+
}
|
|
303
|
+
if (indicators.has(field)) {
|
|
304
|
+
throw new ValidationError(`Duplicate --indicator variable "${field}": each variable must bind exactly one indicator`);
|
|
305
|
+
}
|
|
306
|
+
indicators.set(field, { field, indicatorCode, parameters: params.get(field) ?? [] });
|
|
307
|
+
}
|
|
308
|
+
// A param for an unbound variable is silently dropped by the server, so the
|
|
309
|
+
// query would run with a filter the caller believes is applied but is not.
|
|
310
|
+
for (const field of params.keys()) {
|
|
311
|
+
if (!indicators.has(field)) {
|
|
312
|
+
throw new ValidationError(`--indicator-param references "${field}", which no --indicator binds`);
|
|
271
313
|
}
|
|
272
|
-
group.parameters.push({ paramKey, paramValue });
|
|
273
314
|
}
|
|
274
|
-
return [...
|
|
315
|
+
return [...indicators.values()];
|
|
275
316
|
}
|
|
@@ -5,7 +5,7 @@ import { isTransientError } from "./transport.js";
|
|
|
5
5
|
export const POLL_MAX_ATTEMPTS = 14;
|
|
6
6
|
const POLL_INITIAL_DELAY_MS = 5_000;
|
|
7
7
|
const POLL_MAX_DELAY_MS = 30_000;
|
|
8
|
-
function
|
|
8
|
+
export function nextPollDelayMs(attempt) {
|
|
9
9
|
// 5s, 8s, 13s, 20s, 30s, 30s, ...
|
|
10
10
|
const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
|
|
11
11
|
return Math.min(POLL_MAX_DELAY_MS, Math.round(grown));
|
|
@@ -19,7 +19,9 @@ function nextDelayMs(attempt) {
|
|
|
19
19
|
// recognize the pending code aborts the poll on a job already billed 50 credits.
|
|
20
20
|
const PENDING_CODES = new Set(["410110", "140001"]);
|
|
21
21
|
const FAILED_CODES = new Set(["410111", "140002"]);
|
|
22
|
-
|
|
22
|
+
/** Shared by the AI content endpoints and `tool file-parse` — both report
|
|
23
|
+
* "still generating" with the same codes. */
|
|
24
|
+
export function isAsyncPending(error) {
|
|
23
25
|
return error instanceof ApiError && error.code !== undefined && PENDING_CODES.has(error.code);
|
|
24
26
|
}
|
|
25
27
|
function isAsyncFailed(error) {
|
|
@@ -66,7 +68,7 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
69
|
-
const delay =
|
|
71
|
+
const delay = nextPollDelayMs(attempt);
|
|
70
72
|
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in ${Math.round(delay / 1000)}s...\n`);
|
|
71
73
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
72
74
|
}
|
package/dist/src/core/client.js
CHANGED
|
@@ -2,12 +2,12 @@ import { createWriteStream } from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
|
-
import { request } from "undici";
|
|
5
|
+
import { FormData, request } from "undici";
|
|
6
6
|
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
7
7
|
import { ApiError, attachEnvelopeTraceId, ValidationError } from "./errors.js";
|
|
8
8
|
import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
|
|
9
9
|
import { getLookupData } from "./lookupData/index.js";
|
|
10
|
-
import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, runWithConcurrency, withRetry } from "./transport.js";
|
|
10
|
+
import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runWithConcurrency, withRetry } from "./transport.js";
|
|
11
11
|
// Auth errors that warrant a forced re-login + one replay: the token was rejected
|
|
12
12
|
// server-side while still looking valid by local expiry, so only a forced refresh
|
|
13
13
|
// recovers it. 0000001008 is the legacy code (probed 2026-07-20: still what the
|
|
@@ -343,12 +343,17 @@ export class GangtiseClient {
|
|
|
343
343
|
const authState = { retried: false };
|
|
344
344
|
const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
|
|
345
345
|
return withRetry(async () => {
|
|
346
|
+
// An upload endpoint hands undici the FormData itself: undici derives the
|
|
347
|
+
// multipart content-type (boundary included) from the body, so setting one
|
|
348
|
+
// here would corrupt the request.
|
|
349
|
+
const isUpload = endpoint.kind === 'upload';
|
|
346
350
|
const headers = {
|
|
347
|
-
'content-type': 'application/json',
|
|
348
351
|
// undici does not auto-decompress; decodeResponseBody gunzips below. Server
|
|
349
352
|
// gzip cuts JSON payloads ~3-10x (measured 3.6x on constant-list).
|
|
350
353
|
'accept-encoding': 'gzip',
|
|
351
354
|
};
|
|
355
|
+
if (!isUpload)
|
|
356
|
+
headers['content-type'] = 'application/json';
|
|
352
357
|
// Keep the header we actually sent: the self-heal check compares it against
|
|
353
358
|
// the current memoCache token to tell "stale token" from "fresh token died".
|
|
354
359
|
let usedAuthorization;
|
|
@@ -360,7 +365,7 @@ export class GangtiseClient {
|
|
|
360
365
|
const response = await request(url, {
|
|
361
366
|
method: endpoint.method,
|
|
362
367
|
headers,
|
|
363
|
-
body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
|
|
368
|
+
body: endpoint.method === 'GET' ? undefined : (isUpload ? body : JSON.stringify(body ?? {})),
|
|
364
369
|
headersTimeout: timeoutMs,
|
|
365
370
|
bodyTimeout: timeoutMs,
|
|
366
371
|
dispatcher,
|
|
@@ -392,7 +397,7 @@ export class GangtiseClient {
|
|
|
392
397
|
const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
|
|
393
398
|
let parsed;
|
|
394
399
|
try {
|
|
395
|
-
parsed = JSON.parse(text);
|
|
400
|
+
parsed = JSON.parse(quoteBigIntFields(text, endpoint.bigIntFields));
|
|
396
401
|
}
|
|
397
402
|
catch {
|
|
398
403
|
const message = response.statusCode >= 400
|
|
@@ -434,7 +439,22 @@ export class GangtiseClient {
|
|
|
434
439
|
throw error;
|
|
435
440
|
});
|
|
436
441
|
}
|
|
437
|
-
|
|
442
|
+
/** POST a file as multipart/form-data under the field name `file`. Reuses
|
|
443
|
+
* requestJson for auth / retry / envelope handling — only the body differs. */
|
|
444
|
+
async uploadFile(endpointKey, file) {
|
|
445
|
+
const endpoint = ENDPOINTS[endpointKey];
|
|
446
|
+
if (!endpoint || endpoint.kind !== 'upload') {
|
|
447
|
+
throw new ApiError(`Not an upload endpoint: ${endpointKey}`);
|
|
448
|
+
}
|
|
449
|
+
const form = new FormData();
|
|
450
|
+
// Cast: TS 5.7+ types Uint8Array as Uint8Array<ArrayBufferLike>, which BlobPart
|
|
451
|
+
// (ArrayBufferView<ArrayBuffer>) rejects; a Node Buffer is always ArrayBuffer-backed.
|
|
452
|
+
form.append('file', new Blob([file.data], { type: file.contentType ?? 'application/octet-stream' }), file.filename);
|
|
453
|
+
return this.requestJson(endpoint, form);
|
|
454
|
+
}
|
|
455
|
+
/** `body` is only sent for POST download endpoints (the file-parse result
|
|
456
|
+
* endpoint takes `{taskId}` as JSON and answers with the ZIP bytes). */
|
|
457
|
+
async download(endpoint, query, options, body) {
|
|
438
458
|
const dispatcher = getDispatcher();
|
|
439
459
|
const url = this.buildUrl(endpoint.path);
|
|
440
460
|
Object.entries(query).forEach(([key, value]) => {
|
|
@@ -446,9 +466,13 @@ export class GangtiseClient {
|
|
|
446
466
|
const startedAt = Date.now();
|
|
447
467
|
let currentUrl = url;
|
|
448
468
|
let auth = authorization;
|
|
469
|
+
const isPost = endpoint.method === 'POST';
|
|
449
470
|
let response = await request(currentUrl, {
|
|
450
471
|
method: endpoint.method,
|
|
451
|
-
headers:
|
|
472
|
+
headers: isPost
|
|
473
|
+
? { Authorization: authorization, 'content-type': 'application/json' }
|
|
474
|
+
: { Authorization: authorization },
|
|
475
|
+
body: isPost ? JSON.stringify(body ?? {}) : undefined,
|
|
452
476
|
headersTimeout: this.config.timeoutMs,
|
|
453
477
|
bodyTimeout: this.config.timeoutMs,
|
|
454
478
|
dispatcher,
|
|
@@ -488,7 +512,7 @@ export class GangtiseClient {
|
|
|
488
512
|
const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
|
|
489
513
|
if (contentType?.includes('application/json')) {
|
|
490
514
|
const text = await response.body.text();
|
|
491
|
-
logTiming(
|
|
515
|
+
logTiming(`${endpoint.method} ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
492
516
|
let parsed;
|
|
493
517
|
try {
|
|
494
518
|
parsed = JSON.parse(text);
|
|
@@ -517,7 +541,7 @@ export class GangtiseClient {
|
|
|
517
541
|
}
|
|
518
542
|
if (contentType?.includes('text/plain') || contentType?.includes('text/html')) {
|
|
519
543
|
const text = await response.body.text();
|
|
520
|
-
logTiming(
|
|
544
|
+
logTiming(`${endpoint.method} ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
521
545
|
if (response.statusCode >= 400) {
|
|
522
546
|
throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
|
|
523
547
|
}
|
|
@@ -560,11 +584,11 @@ export class GangtiseClient {
|
|
|
560
584
|
await fs.unlink(partPath).catch(() => { });
|
|
561
585
|
throw error;
|
|
562
586
|
}
|
|
563
|
-
logTiming(
|
|
587
|
+
logTiming(`${endpoint.method} ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
|
|
564
588
|
return { contentType, filename, savedPath: options.streamTo };
|
|
565
589
|
}
|
|
566
590
|
const buffer = await response.body.arrayBuffer();
|
|
567
|
-
logTiming(
|
|
591
|
+
logTiming(`${endpoint.method} ${endpoint.path} (binary)`, Date.now() - startedAt, `${response.statusCode}, ${buffer.byteLength}B`);
|
|
568
592
|
return {
|
|
569
593
|
data: new Uint8Array(buffer),
|
|
570
594
|
contentType,
|
|
@@ -587,8 +611,11 @@ export class GangtiseClient {
|
|
|
587
611
|
if (!endpoint) {
|
|
588
612
|
throw new ApiError(`Unknown endpoint key: ${endpointKey}`);
|
|
589
613
|
}
|
|
614
|
+
if (endpoint.kind === 'upload') {
|
|
615
|
+
throw new ValidationError(`${endpointKey} takes a file upload — use 'gangtise tool file-parse --file <path>' ('raw call' cannot send files)`);
|
|
616
|
+
}
|
|
590
617
|
if (endpoint.kind === 'download') {
|
|
591
|
-
return this.download(endpoint, query ?? {}, options);
|
|
618
|
+
return this.download(endpoint, query ?? {}, options, body);
|
|
592
619
|
}
|
|
593
620
|
if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
|
|
594
621
|
return this.requestPaginated(endpoint, body);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseSize } from "./args.js";
|
|
1
|
+
import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators, parseSize } from "./args.js";
|
|
2
2
|
export function buildQuoteKlineBody(options) {
|
|
3
3
|
return {
|
|
4
4
|
securityList: maybeArray(options.security),
|
|
@@ -34,25 +34,61 @@ export function buildStockPoolStocksBody(options) {
|
|
|
34
34
|
poolIdList: options.poolId?.length ? options.poolId : ["all"],
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
/** Parameter keys that carry an indicator's own query date. The 2026-08-01 EDE
|
|
38
|
+
* revision dropped the root-level `date`, so `--date` now fans out into each
|
|
39
|
+
* indicator's `tradeDate`. Report-period indicators read `reportDate`/`sDate`
|
|
40
|
+
* instead and answer a `tradeDate` with an EMPTY result rather than an error
|
|
41
|
+
* (probed 2026-08-01 on `is_op_rev_mom`), so a caller-supplied date of any of
|
|
42
|
+
* these kinds suppresses the injection for that indicator. Injecting into an
|
|
43
|
+
* indicator that takes no date at all is harmless — `pty_op_scope` still
|
|
44
|
+
* answers normally with a stray `tradeDate` attached. */
|
|
45
|
+
const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate", "sDate"]);
|
|
46
|
+
function withQueryDate(groups, codes, date) {
|
|
47
|
+
const merged = new Map();
|
|
48
|
+
for (const group of groups ?? [])
|
|
49
|
+
merged.set(group.indicatorCode, group);
|
|
50
|
+
for (const code of codes) {
|
|
51
|
+
const group = merged.get(code);
|
|
52
|
+
if (!group) {
|
|
53
|
+
merged.set(code, { indicatorCode: code, parameters: [{ paramKey: "tradeDate", paramValue: date }] });
|
|
54
|
+
}
|
|
55
|
+
else if (!group.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))) {
|
|
56
|
+
group.parameters.push({ paramKey: "tradeDate", paramValue: date });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...merged.values()];
|
|
60
|
+
}
|
|
37
61
|
export function buildIndicatorCrossSectionBody(options) {
|
|
38
62
|
return {
|
|
39
63
|
indicatorCodeList: maybeArray(options.indicator),
|
|
40
|
-
|
|
41
|
-
date: options.date,
|
|
64
|
+
universe: maybeArray(options.security),
|
|
42
65
|
currency: options.currency,
|
|
43
66
|
scale: options.scale,
|
|
44
|
-
indicatorParamList: parseIndicatorParams(options.indicatorParam),
|
|
67
|
+
indicatorParamList: withQueryDate(parseIndicatorParams(options.indicatorParam), options.indicator, options.date),
|
|
45
68
|
};
|
|
46
69
|
}
|
|
47
70
|
export function buildIndicatorTimeSeriesBody(options) {
|
|
48
71
|
return {
|
|
49
72
|
indicatorCodeList: maybeArray(options.indicator),
|
|
50
|
-
|
|
73
|
+
universe: maybeArray(options.security),
|
|
51
74
|
startDate: options.startDate,
|
|
52
75
|
endDate: options.endDate,
|
|
53
76
|
calendarType: options.calendarType,
|
|
54
77
|
currency: options.currency,
|
|
55
78
|
scale: options.scale,
|
|
56
|
-
|
|
79
|
+
// The endpoint requires the key even with nothing to configure.
|
|
80
|
+
indicatorParamList: parseIndicatorParams(options.indicatorParam) ?? [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function buildIndicatorScreenerBody(options) {
|
|
84
|
+
const indicators = parseScreenerIndicators(options.indicator, options.indicatorParam);
|
|
85
|
+
return {
|
|
86
|
+
universe: maybeArray(options.security),
|
|
87
|
+
expression: options.expression,
|
|
88
|
+
indicatorList: options.date
|
|
89
|
+
? indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
|
|
90
|
+
? indicator
|
|
91
|
+
: { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] }))
|
|
92
|
+
: indicators,
|
|
57
93
|
};
|
|
58
94
|
}
|
|
@@ -77,6 +77,19 @@ const ENDPOINT_DEFS = {
|
|
|
77
77
|
description: "List forums",
|
|
78
78
|
pagination: { enabled: true, maxPageSize: 50 },
|
|
79
79
|
},
|
|
80
|
+
"insight.performance-calendar.list": {
|
|
81
|
+
method: "POST",
|
|
82
|
+
path: "/application/open-insight/schedule/performance-calendar/getList",
|
|
83
|
+
kind: "json",
|
|
84
|
+
description: "List earnings calendar events (forecast / express / announcement)",
|
|
85
|
+
pagination: { enabled: true, maxPageSize: 50 },
|
|
86
|
+
},
|
|
87
|
+
"insight.performance-calendar.download": {
|
|
88
|
+
method: "GET",
|
|
89
|
+
path: "/application/open-insight/schedule/performance-calendar/download/file",
|
|
90
|
+
kind: "download",
|
|
91
|
+
description: "Download an earnings report file (A-share 10 credits, HK/US 20)",
|
|
92
|
+
},
|
|
80
93
|
"insight.research.list": {
|
|
81
94
|
method: "POST",
|
|
82
95
|
path: "/application/open-insight/broker-report/getList",
|
|
@@ -619,6 +632,36 @@ const ENDPOINT_DEFS = {
|
|
|
619
632
|
description: "Get time-series data (multi-indicator x single-security OR single-indicator x multi-security)",
|
|
620
633
|
retry: "no-999999",
|
|
621
634
|
},
|
|
635
|
+
// Note the path: the screener sits directly under open-indicator, NOT under
|
|
636
|
+
// the EDE/ prefix its three siblings share.
|
|
637
|
+
"indicator.screener": {
|
|
638
|
+
method: "POST",
|
|
639
|
+
path: "/application/open-indicator/screener",
|
|
640
|
+
kind: "json",
|
|
641
|
+
description: "Screen securities by an expression over indicator values (条件选股)",
|
|
642
|
+
retry: "no-999999",
|
|
643
|
+
},
|
|
644
|
+
// ─── tool (open-tool: async file parsing) ───
|
|
645
|
+
"tool.file-parse.submit": {
|
|
646
|
+
method: "POST",
|
|
647
|
+
path: "/application/open-tool/file-parse/submit",
|
|
648
|
+
kind: "upload",
|
|
649
|
+
description: "Submit a PDF for parsing (multipart upload), returns taskId",
|
|
650
|
+
// Billed per page (0.8/页) at submit time, and the upload itself can take
|
|
651
|
+
// minutes on a 100MB file — never replay it, and don't let the default 30s
|
|
652
|
+
// headers timeout kill an in-flight upload.
|
|
653
|
+
timeoutMs: 300_000,
|
|
654
|
+
retry: "no-replay",
|
|
655
|
+
// Probed 2026-07-25: taskId comes back as a string today. Guard anyway — if it
|
|
656
|
+
// ever arrives as a bare number, rounding would strand a paid parse job.
|
|
657
|
+
bigIntFields: ["taskId"],
|
|
658
|
+
},
|
|
659
|
+
"tool.file-parse.result": {
|
|
660
|
+
method: "POST",
|
|
661
|
+
path: "/application/open-tool/file-parse/result",
|
|
662
|
+
kind: "download",
|
|
663
|
+
description: "Fetch a file-parse result ZIP by taskId (140001 = still generating)",
|
|
664
|
+
},
|
|
622
665
|
};
|
|
623
666
|
export const ENDPOINTS = Object.fromEntries(Object.entries(ENDPOINT_DEFS).map(([key, def]) => [key, { key, ...def }]));
|
|
624
667
|
/** Flat catalog of every registered endpoint, for `raw list` discoverability
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isAsyncPending, nextPollDelayMs, POLL_MAX_ATTEMPTS } from "./asyncContent.js";
|
|
4
|
+
import { saveDownloadResult } from "./download.js";
|
|
5
|
+
import { ApiError, ValidationError } from "./errors.js";
|
|
6
|
+
import { isTransientError } from "./transport.js";
|
|
7
|
+
/** Server-side upload cap (spec: ≤100MB, ≤500 pages). Checked locally so a
|
|
8
|
+
* doomed 100MB+ upload fails instantly instead of after a long transfer. */
|
|
9
|
+
export const FILE_PARSE_MAX_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
/** Upload a PDF and return its taskId. Billed at submit time (0.8 credits/page),
|
|
11
|
+
* so every reason to reject a file is checked before the request goes out. */
|
|
12
|
+
export async function submitFileParse(client, filePath) {
|
|
13
|
+
const resolved = path.resolve(filePath);
|
|
14
|
+
let stat;
|
|
15
|
+
try {
|
|
16
|
+
stat = await fs.stat(resolved);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new ValidationError(`File not found: ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
if (!stat.isFile())
|
|
22
|
+
throw new ValidationError(`Not a file: ${filePath}`);
|
|
23
|
+
if (stat.size === 0)
|
|
24
|
+
throw new ValidationError(`File is empty: ${filePath}`);
|
|
25
|
+
if (stat.size > FILE_PARSE_MAX_BYTES) {
|
|
26
|
+
throw new ValidationError(`File is ${(stat.size / 1024 / 1024).toFixed(1)}MB — the parse API accepts at most 100MB`);
|
|
27
|
+
}
|
|
28
|
+
if (path.extname(resolved).toLowerCase() !== ".pdf") {
|
|
29
|
+
throw new ValidationError(`Only PDF files are supported: ${filePath}`);
|
|
30
|
+
}
|
|
31
|
+
// Buffered, not streamed: undici's FormData needs a Blob, so a 100MB file peaks
|
|
32
|
+
// at ~2× its size in memory. Acceptable against the API's own 100MB ceiling —
|
|
33
|
+
// switch to a hand-rolled streaming multipart body if that ceiling ever rises.
|
|
34
|
+
const data = await fs.readFile(resolved);
|
|
35
|
+
const result = await client.uploadFile("tool.file-parse.submit", {
|
|
36
|
+
filename: path.basename(resolved),
|
|
37
|
+
data,
|
|
38
|
+
contentType: "application/pdf",
|
|
39
|
+
});
|
|
40
|
+
const taskId = result?.taskId;
|
|
41
|
+
if (taskId === undefined || taskId === null || taskId === "") {
|
|
42
|
+
throw new ApiError("File parse task was accepted but the response carried no taskId", undefined, undefined, result);
|
|
43
|
+
}
|
|
44
|
+
return String(taskId);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One result fetch. "pending" = the server answered with the generating code
|
|
48
|
+
* (140001 / legacy 410110); "ok" = the ZIP was written and its path printed.
|
|
49
|
+
* Free to call — only the submit step is billed.
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchFileParseResult(client, taskId, output) {
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await client.call("tool.file-parse.result", { taskId }, undefined, output ? { streamTo: output } : undefined);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (isAsyncPending(error))
|
|
58
|
+
return "pending";
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
await saveDownloadResult(result, `file-parse-${taskId}`, output);
|
|
62
|
+
return "ok";
|
|
63
|
+
}
|
|
64
|
+
/** Poll until the ZIP is ready. Same backoff budget as the AI async endpoints
|
|
65
|
+
* (≈316s), which covers the documented ~3 min parse time. */
|
|
66
|
+
export async function pollFileParseResult(client, taskId, output) {
|
|
67
|
+
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
if (await fetchFileParseResult(client, taskId, output) === "ok")
|
|
70
|
+
return "ok";
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// The task is already paid for: a blip must not void the wait. Transient
|
|
74
|
+
// errors consume the attempt, anything else (bad taskId, no permission) aborts.
|
|
75
|
+
if (!isTransientError(error))
|
|
76
|
+
throw error;
|
|
77
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
78
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: transient error (${msg.slice(0, 80)}), continuing to wait...\n`);
|
|
79
|
+
}
|
|
80
|
+
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
81
|
+
const delay = nextPollDelayMs(attempt);
|
|
82
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: parse result not ready, retrying in ${Math.round(delay / 1000)}s...\n`);
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return "timeout";
|
|
87
|
+
}
|
|
@@ -31,23 +31,32 @@ export function unwrapIndicatorData(raw) {
|
|
|
31
31
|
function asStringArray(value) {
|
|
32
32
|
return Array.isArray(value) ? value.map((item) => String(item)) : undefined;
|
|
33
33
|
}
|
|
34
|
+
function asIndicatorMetaList(value) {
|
|
35
|
+
if (!Array.isArray(value))
|
|
36
|
+
return undefined;
|
|
37
|
+
return value.map((item) => (item && typeof item === "object" && !Array.isArray(item) ? item : {}));
|
|
38
|
+
}
|
|
39
|
+
function optionalString(value) {
|
|
40
|
+
return value === undefined || value === null ? undefined : String(value);
|
|
41
|
+
}
|
|
34
42
|
function rowOf(values, index) {
|
|
35
43
|
const row = values[index];
|
|
36
44
|
return Array.isArray(row) ? row : undefined;
|
|
37
45
|
}
|
|
38
46
|
// Build one column header per series. Prefer the human-readable name; on a
|
|
39
|
-
// duplicate name append
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
// duplicate name append a disambiguator so a column is never silently
|
|
48
|
+
// overwritten. `reserved` pre-seeds the metadata column names, so a series
|
|
49
|
+
// literally named "date" / "security" / "name" gets suffixed rather than
|
|
50
|
+
// clobbering the metadata.
|
|
51
|
+
function buildHeaders(bases, suffixes, reserved) {
|
|
52
|
+
const used = new Set(reserved);
|
|
44
53
|
const headers = [];
|
|
45
|
-
for (let i = 0; i <
|
|
46
|
-
const base =
|
|
54
|
+
for (let i = 0; i < bases.length; i++) {
|
|
55
|
+
const base = bases[i] ?? suffixes[i] ?? `col${i}`;
|
|
47
56
|
let header = base;
|
|
48
57
|
let attempt = 1;
|
|
49
58
|
while (used.has(header)) {
|
|
50
|
-
const suffix =
|
|
59
|
+
const suffix = suffixes[i] ?? i;
|
|
51
60
|
header = attempt === 1 ? `${base} (${suffix})` : `${base} (${suffix})_${attempt}`;
|
|
52
61
|
attempt++;
|
|
53
62
|
}
|
|
@@ -56,27 +65,40 @@ function buildHeaders(names, codes, count) {
|
|
|
56
65
|
}
|
|
57
66
|
return headers;
|
|
58
67
|
}
|
|
59
|
-
//
|
|
60
|
-
// `
|
|
61
|
-
// order
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
68
|
+
// Column headers for a list of indicator metadata. keyBy "code" makes each
|
|
69
|
+
// column its `code` (unique, and independent of the display name or the
|
|
70
|
+
// server's column order) instead of the human name — required for batch
|
|
71
|
+
// code→value mapping, where names collide (many indicators share a display
|
|
72
|
+
// name) and the server reorders columns relative to the request. The screener
|
|
73
|
+
// disambiguates by `field` instead, because there the SAME code may legitimately
|
|
74
|
+
// appear twice under two variables with different parameters.
|
|
75
|
+
function indicatorHeaders(indicators, keyBy, reserved) {
|
|
76
|
+
const codes = indicators.map((meta) => optionalString(meta.code));
|
|
77
|
+
const fields = indicators.map((meta, i) => optionalString(meta.field) ?? codes[i]);
|
|
78
|
+
const bases = keyBy === "code" ? codes : indicators.map((meta) => optionalString(meta.name));
|
|
79
|
+
return buildHeaders(bases, fields, reserved);
|
|
80
|
+
}
|
|
81
|
+
const CROSS_SECTION_COLUMNS = ["security", "name"];
|
|
82
|
+
// Cross-section (and the screener, whose payload is the same shape plus a
|
|
83
|
+
// per-indicator `field`): one row per security, one column per indicator.
|
|
84
|
+
// `values` is [security][indicator], so security i's value for indicator j is
|
|
85
|
+
// values[i][j]. There is no row-level date any more — the query date now lives
|
|
86
|
+
// in each indicator's own parameters and may legitimately differ per column.
|
|
66
87
|
export function flattenCrossSection(data, keyBy = "name") {
|
|
67
88
|
if (!data || typeof data !== "object")
|
|
68
89
|
return data;
|
|
69
90
|
const d = data;
|
|
70
91
|
const securityCode = asStringArray(d.securityCodeList);
|
|
71
|
-
const indicators =
|
|
92
|
+
const indicators = asIndicatorMetaList(d.indicatorList);
|
|
72
93
|
if (!Array.isArray(d.values) || !securityCode || !indicators)
|
|
73
94
|
return data;
|
|
74
95
|
const securityName = asStringArray(d.securityNameList);
|
|
75
|
-
const headers =
|
|
76
|
-
const list = securityCode.map((code,
|
|
77
|
-
const row = {
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
const headers = indicatorHeaders(indicators, keyBy, CROSS_SECTION_COLUMNS);
|
|
97
|
+
const list = securityCode.map((code, i) => {
|
|
98
|
+
const row = { security: code, name: securityName?.[i] };
|
|
99
|
+
const cells = rowOf(d.values, i);
|
|
100
|
+
for (let j = 0; j < headers.length; j++) {
|
|
101
|
+
row[headers[j]] = cells?.[j];
|
|
80
102
|
}
|
|
81
103
|
return row;
|
|
82
104
|
});
|
|
@@ -84,20 +106,24 @@ export function flattenCrossSection(data, keyBy = "name") {
|
|
|
84
106
|
}
|
|
85
107
|
// Time-series: one row per date. Columns are the indicators (single-security
|
|
86
108
|
// case) or the securities (single-indicator case) — exactly one dimension
|
|
87
|
-
// varies, per the API contract. `values`
|
|
109
|
+
// varies, per the API contract. `values` stays a 2D [series][date] matrix.
|
|
88
110
|
export function flattenTimeSeries(data, keyBy = "name") {
|
|
89
111
|
if (!data || typeof data !== "object")
|
|
90
112
|
return data;
|
|
91
113
|
const d = data;
|
|
92
114
|
const dates = asStringArray(d.dates);
|
|
93
115
|
const securityCode = asStringArray(d.securityCodeList);
|
|
94
|
-
const indicators =
|
|
116
|
+
const indicators = asIndicatorMetaList(d.indicatorList);
|
|
95
117
|
if (!Array.isArray(d.values) || !dates || !securityCode || !indicators)
|
|
96
118
|
return data;
|
|
119
|
+
const securityName = asStringArray(d.securityNameList);
|
|
97
120
|
const seriesAreIndicators = securityCode.length <= 1;
|
|
121
|
+
// Map over securityCode rather than securityNameList directly: a response that
|
|
122
|
+
// omits the names must still yield one header per security (falling back to
|
|
123
|
+
// the code), not zero columns.
|
|
98
124
|
const headers = seriesAreIndicators
|
|
99
|
-
?
|
|
100
|
-
: buildHeaders(keyBy === "code" ?
|
|
125
|
+
? indicatorHeaders(indicators, keyBy, ["date"])
|
|
126
|
+
: buildHeaders(securityCode.map((code, i) => (keyBy === "code" ? code : securityName?.[i])), securityCode, ["date"]);
|
|
101
127
|
const list = dates.map((date, k) => {
|
|
102
128
|
const row = { date };
|
|
103
129
|
for (let i = 0; i < headers.length; i++) {
|
|
@@ -9,6 +9,26 @@ export function decodeResponseBody(buf, contentEncoding) {
|
|
|
9
9
|
const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
10
10
|
return enc === "gzip" ? gunzipSync(b).toString("utf8") : b.toString("utf8");
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Re-quote the *unquoted numeric* values of the named JSON fields so ids past
|
|
14
|
+
* 2^53 survive JSON.parse: `{"taskId":1782345678901234567}` would otherwise come
|
|
15
|
+
* back as 1782345678901234700 and address a task that does not exist.
|
|
16
|
+
*
|
|
17
|
+
* Probed 2026-07-25: file-parse's submit returns `taskId` as a STRING (matching
|
|
18
|
+
* the spec), so this is a forward guard, not a live fix — but the failure mode it
|
|
19
|
+
* covers is silent and expensive (the parse job is already billed, and a rounded
|
|
20
|
+
* id can never fetch its result). Values already in quotes are left alone: the
|
|
21
|
+
* pattern only matches a bare number right after the key.
|
|
22
|
+
*/
|
|
23
|
+
export function quoteBigIntFields(text, fields) {
|
|
24
|
+
if (!fields?.length)
|
|
25
|
+
return text;
|
|
26
|
+
let out = text;
|
|
27
|
+
for (const field of fields) {
|
|
28
|
+
out = out.replace(new RegExp(`("${field}"\\s*:\\s*)(-?\\d+)`, "g"), '$1"$2"');
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
12
32
|
/** A Retry-After delay we'll honor even if it exceeds maxDelay — but never past this
|
|
13
33
|
* ceiling, so a hostile/misconfigured header can't hang the CLI for minutes. */
|
|
14
34
|
const RETRY_AFTER_CEILING_MS = 60_000;
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated — DO NOT EDIT
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.30.0";
|