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.
@@ -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 `--indicator-param "code:key=value"` specs into the nested
251
- // indicatorParamList the EDE cross-section / time-series endpoints expect.
252
- // Multiple specs for the same code accumulate into one group, first-seen order.
253
- export function parseIndicatorParams(specs) {
254
- if (specs.length === 0)
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 code = colon === -1 ? "" : spec.slice(0, colon).trim();
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 (!code || !paramKey) {
265
- throw new ValidationError(`Invalid --indicator-param: expected "code:key=value", got "${spec}"`);
263
+ if (!lhs || !paramKey) {
264
+ throw new ValidationError(`Invalid ${option}: expected "${syntax}", got "${spec}"`);
266
265
  }
267
- let group = groups.get(code);
268
- if (!group) {
269
- group = { indicatorCode: code, parameters: [] };
270
- groups.set(code, group);
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 [...groups.values()];
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 nextDelayMs(attempt) {
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
- function isAsyncPending(error) {
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 = nextDelayMs(attempt);
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
  }
@@ -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
- async download(endpoint, query, options) {
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: { Authorization: authorization },
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(`GET ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
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(`GET ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
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(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
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(`GET ${endpoint.path} (binary)`, Date.now() - startedAt, `${response.statusCode}, ${buffer.byteLength}B`);
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
- securityCodeList: maybeArray(options.security),
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
- securityCodeList: maybeArray(options.security),
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
- indicatorParamList: parseIndicatorParams(options.indicatorParam),
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 the code so a column is never silently overwritten.
40
- function buildHeaders(names, codes, count) {
41
- // Pre-seed the metadata column names: an indicator literally named "date" /
42
- // "security" / "name" must get a suffixed header, not overwrite the metadata.
43
- const used = new Set(["date", "security", "name"]);
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 < count; i++) {
46
- const base = String(names?.[i] ?? codes?.[i] ?? `col${i}`);
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 = codes?.[i] ?? i;
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
- // Cross-section: one row per security, one column per indicator. The live
60
- // `values` is a 2D [numIndicators][numSecurities] matrix in indicator-major
61
- // order, so indicator i on security j is values[i][j].
62
- // keyBy "code" makes each indicator column its `indicatorCode` (unique, and
63
- // independent of the display name or the server's column order) instead of the
64
- // human name — required for batch code→value mapping, where names collide
65
- // (many indicators share a display name) and the server reorders columns.
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 = asStringArray(d.indicatorCodeList);
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 = buildHeaders(keyBy === "code" ? indicators : asStringArray(d.indicatorNameList), indicators, indicators.length);
76
- const list = securityCode.map((code, j) => {
77
- const row = { date: d.date, security: code, name: securityName?.[j] };
78
- for (let i = 0; i < indicators.length; i++) {
79
- row[headers[i]] = rowOf(d.values, i)?.[j];
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` is a 2D [series][date] matrix.
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 = asStringArray(d.indicatorCodeList);
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
- ? buildHeaders(keyBy === "code" ? indicators : asStringArray(d.indicatorNameList), indicators, indicators.length)
100
- : buildHeaders(keyBy === "code" ? securityCode : asStringArray(d.securityNameList), securityCode, securityCode.length);
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;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.28.3";
2
+ export const CLI_VERSION = "0.30.0";