gangtise-openapi-cli 0.30.0 → 0.31.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.
@@ -1,4 +1,5 @@
1
- import { ApiError } from "./errors.js";
1
+ import { screenerExpressionFields, screenerExpressionIsEvaluable } from "./args.js";
2
+ import { ApiError, attachEnvelopeTraceId, ENVELOPE_TRACE_ID } from "./errors.js";
2
3
  // The EDE endpoints double-wrap on success: the shared client strips the outer
3
4
  // envelope but leaves an inner { code, status, data } around the real payload.
4
5
  // Peel that inner envelope so the list (search) / matrix (cross-section,
@@ -22,8 +23,12 @@ export function unwrapIndicatorData(raw) {
22
23
  // 999999 / 130001 that most need reporting — reach the user trace-less.
23
24
  throw new ApiError(typeof record.msg === "string" && record.msg ? record.msg : "Indicator API request failed", code, undefined, record);
24
25
  }
26
+ // Carry the envelope's traceId onto the inner payload. `unwrapEnvelope`
27
+ // attached it to THIS object, but everything downstream (the flatteners'
28
+ // shape guards) only ever sees `record.data` — without this hand-off those
29
+ // failures, exactly the ones support needs to trace, arrive trace-less.
25
30
  if (ok && "data" in record)
26
- return record.data;
31
+ return attachEnvelopeTraceId(record.data, record[ENVELOPE_TRACE_ID] ?? record.traceId);
27
32
  }
28
33
  }
29
34
  return raw;
@@ -31,10 +36,33 @@ export function unwrapIndicatorData(raw) {
31
36
  function asStringArray(value) {
32
37
  return Array.isArray(value) ? value.map((item) => String(item)) : undefined;
33
38
  }
34
- function asIndicatorMetaList(value) {
39
+ /** An IDENTITY axis — the security codes a row belongs to, the dates a column
40
+ * belongs to. `String(item)` would turn a `null` into the literal `"null"` and
41
+ * render it as a perfectly plausible label (probed 2026-08-02: `dates: [null]`
42
+ * produced `date: "null"`, `securityCodeList: [null]` produced
43
+ * `security: "null"`), so a fabricated identity would reach the output at exit
44
+ * 0. Nothing here may be coerced: every entry must already be a non-empty string. */
45
+ function asIdentityArray(data, value, key) {
35
46
  if (!Array.isArray(value))
36
47
  return undefined;
37
- return value.map((item) => (item && typeof item === "object" && !Array.isArray(item) ? item : {}));
48
+ const bad = value.findIndex((item) => typeof item !== "string" || item.trim() === "");
49
+ if (bad !== -1) {
50
+ throw new ApiError(`Indicator matrix shape mismatch: ${key}[${bad}] is not a usable identifier (${JSON.stringify(value[bad])}) — rows would be labelled with a value that identifies nothing`, undefined, undefined, data);
51
+ }
52
+ return value;
53
+ }
54
+ /** Indicator metadata is an identity axis too: `--key-by code` addresses columns
55
+ * by `code`, so an entry without one cannot be mapped back to what was asked
56
+ * for. A non-object entry used to collapse into `{}` and surface as `col0`. */
57
+ function asIndicatorMetaList(data, value) {
58
+ if (!Array.isArray(value))
59
+ return undefined;
60
+ const bad = value.findIndex((item) => !item || typeof item !== "object" || Array.isArray(item)
61
+ || typeof item.code !== "string" || String(item.code).trim() === "");
62
+ if (bad !== -1) {
63
+ throw new ApiError(`Indicator matrix shape mismatch: indicatorList[${bad}] carries no usable \`code\` (${JSON.stringify(value[bad])}) — the column could not be mapped back to a requested indicator`, undefined, undefined, data);
64
+ }
65
+ return value;
38
66
  }
39
67
  function optionalString(value) {
40
68
  return value === undefined || value === null ? undefined : String(value);
@@ -76,26 +104,257 @@ function indicatorHeaders(indicators, keyBy, reserved) {
76
104
  const codes = indicators.map((meta) => optionalString(meta.code));
77
105
  const fields = indicators.map((meta, i) => optionalString(meta.field) ?? codes[i]);
78
106
  const bases = keyBy === "code" ? codes : indicators.map((meta) => optionalString(meta.name));
79
- return buildHeaders(bases, fields, reserved);
107
+ // On a screener payload every column carries its variable, so a repeated base
108
+ // can suffix ALL of its occurrences instead of leaving the first one bare.
109
+ // A bare `收盘价` next to `收盘价 (F2)` reads as "the" close price when it is
110
+ // really just whichever variable the server happened to list first.
111
+ const screener = indicators.some((meta) => meta.field !== undefined);
112
+ if (!screener)
113
+ return buildHeaders(bases, fields, reserved);
114
+ const repeated = new Set(bases.filter((base, i) => base !== undefined && bases.indexOf(base) !== i));
115
+ return buildHeaders(bases.map((base, i) => (base !== undefined && repeated.has(base) ? `${base} (${fields[i] ?? i})` : base)), fields, reserved);
80
116
  }
81
117
  const CROSS_SECTION_COLUMNS = ["security", "name"];
118
+ /** A response carrying neither securities nor indicators: the query as a whole
119
+ * resolved to nothing. This is NOT a dropped axis — nothing was left out, there
120
+ * simply is no data (a non-trading range, a date outside coverage), so it must
121
+ * not be reported as partial. Calling every requested code "omitted" here would
122
+ * be false metadata: the diff against the request is total by construction.
123
+ *
124
+ * The check covers every STRUCTURAL array, not just the two axis lists, because
125
+ * accepting anything looser would let a malformed payload — `values: null`, a
126
+ * missing `values`, or dates with no matrix — pass as "legitimately empty" and
127
+ * exit 0, which is precisely the protocol regression this release's shape guards
128
+ * exist to catch. Required empty: `securityCodeList`, `indicatorList`, `values`;
129
+ * plus `dates` when present. Probed 2026-08-02: a time-series no-data answer is
130
+ * five empty arrays, a cross-section one is four (it carries no `dates` key at
131
+ * all), hence "absent or empty" rather than a flat count.
132
+ *
133
+ * `securityNameList` is deliberately NOT checked: it holds display labels, not
134
+ * structure, so a `null` there cannot misalign anything — and rejecting it would
135
+ * re-introduce the false-partial this function exists to prevent. */
136
+ export function isEmptyMatrix(data) {
137
+ if (!data || typeof data !== "object")
138
+ return false;
139
+ const d = data;
140
+ if (asStringArray(d.securityCodeList)?.length !== 0)
141
+ return false;
142
+ if (!Array.isArray(d.indicatorList) || d.indicatorList.length !== 0)
143
+ return false;
144
+ if (!Array.isArray(d.values) || d.values.length !== 0)
145
+ return false;
146
+ // `dates` is time-series only; when present it must be empty too.
147
+ return d.dates === undefined || (Array.isArray(d.dates) && d.dates.length === 0);
148
+ }
149
+ /** Validate the variable bindings a screener answered with, returning the bound
150
+ * variables that are merely missing (see below).
151
+ *
152
+ * The screener answers with the variable each column was requested under, and
153
+ * that binding is the ONLY thing tying a column back to the filter it came from
154
+ * — nothing else in the payload can catch it going wrong. A swapped or unknown
155
+ * `field` renders as a perfectly ordinary result (probed 2026-08-02: a response
156
+ * labelling a requested `F1` as `F9` printed a normal table at exit 0), which
157
+ * would make every screening decision downstream unfounded.
158
+ *
159
+ * Every returned entry must therefore name a REQUESTED variable and carry that
160
+ * variable's code, and no variable may appear twice. A wholly empty result binds
161
+ * nothing and is left alone. */
162
+ export function checkScreenerBindings(data, requested, expression) {
163
+ if (!data || typeof data !== "object")
164
+ return [];
165
+ const d = data;
166
+ // A result with no securities matched nothing and binds nothing — the
167
+ // canonical empty answer, left alone.
168
+ if (!Array.isArray(d.securityCodeList) || d.securityCodeList.length === 0)
169
+ return [];
170
+ const indicators = Array.isArray(d.indicatorList) ? d.indicatorList : [];
171
+ const wanted = new Map(requested.map((binding) => [binding.field, binding.indicatorCode]));
172
+ const seen = new Set();
173
+ for (const [i, meta] of indicators.entries()) {
174
+ const field = optionalString(meta?.field);
175
+ const code = optionalString(meta?.code);
176
+ if (!field || !wanted.has(field)) {
177
+ throw new ApiError(`Screener binding mismatch: indicatorList[${i}] is bound to ${field ? `"${field}"` : "no variable"}, which --indicator never requested — the column cannot be traced to a filter`, undefined, undefined, data);
178
+ }
179
+ if (wanted.get(field) !== code) {
180
+ throw new ApiError(`Screener binding mismatch: variable ${field} came back as "${code}" but was requested as "${wanted.get(field)}" — the filter and the column disagree`, undefined, undefined, data);
181
+ }
182
+ if (seen.has(field)) {
183
+ throw new ApiError(`Screener binding mismatch: variable ${field} appears twice in the response — the mapping back to a filter is ambiguous`, undefined, undefined, data);
184
+ }
185
+ seen.add(field);
186
+ }
187
+ // Securities came back, so the filter ran. A column vanishing is never "it got
188
+ // filtered out" — filtering removes securities (rows), never indicators
189
+ // (columns); the absence means that indicator had no data for any matched
190
+ // security. What that implies depends on how the expression combines terms:
191
+ //
192
+ // The answer follows the expression's BOOLEAN STRUCTURE, not the mere presence
193
+ // of a `||` — see screenerExpressionIsEvaluable. If no branch survives the
194
+ // missing columns, the rows cannot be shown to satisfy anything and the result
195
+ // is fatal; if some branch still could have matched, the absence proves nothing
196
+ // wrong and it degrades to partial.
197
+ const missing = [...wanted.keys()].filter((field) => !seen.has(field));
198
+ if (!screenerExpressionIsEvaluable(expression, seen)) {
199
+ const absent = [...new Set(screenerExpressionFields(expression))].filter((field) => !seen.has(field));
200
+ throw new ApiError(`Screener binding mismatch: the expression cannot be evaluated from what came back — ${absent.join(", ")} ${absent.length > 1 ? "have" : "has"} no column, and no branch of the expression survives without ${absent.length > 1 ? "them" : "it"}, so the rows cannot be shown to satisfy it`, undefined, undefined, data);
201
+ }
202
+ // Whatever survives is an output column that went missing: information lost,
203
+ // correctness intact.
204
+ return missing;
205
+ }
206
+ /** What the caller asked for that the response does not contain. The server
207
+ * does NOT pad missing data with `null`: an indicator that is empty for every
208
+ * security disappears from `indicatorList`, and a security that is empty for
209
+ * every indicator disappears from `securityCodeList` (re-probed 2026-08-03 with
210
+ * `finc_pb_mrq`, which has no HK data: over 09992.HK alongside `qte_close` the
211
+ * indicator vanishes; over 600519.SH + 09992.HK as the only indicator the
212
+ * security vanishes). A gap becomes `null` only when BOTH axes survive it — the
213
+ * security still has another indicator with data AND the indicator still has
214
+ * another security with data.
215
+ *
216
+ * That is the dangerous shape: `--key-by code` batch mapping finds no key at
217
+ * all rather than a null, and a cross-market pull quietly returns fewer rows
218
+ * than requested with exit code 0.
219
+ *
220
+ * Universe entries with no `.` are skipped — those are sector IDs, which the
221
+ * server expands into constituents, so their absence from the response is
222
+ * expected rather than a dropped row. */
223
+ export function droppedFromMatrix(data, requestedSecurities, requestedIndicators) {
224
+ const empty = { securities: [], indicators: [] };
225
+ if (!data || typeof data !== "object")
226
+ return empty;
227
+ const d = data;
228
+ const returnedSecurities = new Set(asStringArray(d.securityCodeList) ?? []);
229
+ const returnedIndicators = new Set((Array.isArray(d.indicatorList) ? d.indicatorList : []).map((meta) => optionalString(meta?.code)));
230
+ return {
231
+ securities: requestedSecurities.filter((code) => code.includes(".") && !returnedSecurities.has(code)),
232
+ indicators: requestedIndicators.filter((code) => !returnedIndicators.has(code)),
233
+ };
234
+ }
235
+ /** True only when the payload carries NONE of the EDE matrix keys — a foreign
236
+ * shape. Nothing legitimately reaches the flatteners in that state — `indicator
237
+ * search` prints its unwrapped list directly and `raw call` bypasses them — so
238
+ * this is a protocol failure, not a payload to hand back: returning it would
239
+ * print the raw envelope and exit 0, indistinguishable from success. */
240
+ function assertMatrixPayload(data, d) {
241
+ const bare = d.securityCodeList === undefined && d.securityNameList === undefined
242
+ && d.indicatorList === undefined && d.dates === undefined && d.values === undefined;
243
+ if (bare) {
244
+ throw new ApiError("Indicator matrix shape mismatch: the response carries none of the matrix fields — the response layout may have changed", undefined, undefined, data);
245
+ }
246
+ }
247
+ function assertAxis(data, axis, key) {
248
+ if (axis === undefined) {
249
+ throw new ApiError(`Indicator matrix shape mismatch: the response is missing \`${key}\` or it is not an array — the response layout may have changed`, undefined, undefined, data);
250
+ }
251
+ }
252
+ /** Resolve the display names for a security axis, degrading instead of failing.
253
+ *
254
+ * Names are consumed POSITIONALLY, so a list that does not line up cannot be
255
+ * trusted at all: `["泡泡玛特"]` against `["600519.SH","09992.HK"]` labels 茅台's
256
+ * series 泡泡玛特, and a `[null]` element used to render a column literally
257
+ * headed `"null"` (both probed 2026-08-02). Neither may reach the output.
258
+ *
259
+ * But a name is a LABEL, not structure — `securityCodeList` already carries
260
+ * identity, and every header falls back to the code. So an anomaly here drops
261
+ * the names and keeps the values, rather than killing an EDE command whose
262
+ * numbers are all correct. That is a deliberate asymmetry with the other guards
263
+ * in this module: they protect against MISATTRIBUTED VALUES and must be fatal;
264
+ * this one protects a caption. */
265
+ function resolveSecurityNames(names, codes) {
266
+ if (names === undefined || names === null)
267
+ return undefined;
268
+ if (!Array.isArray(names) || names.length !== codes.length) {
269
+ process.stderr.write(`[gangtise] warning: securityNameList carries ${Array.isArray(names) ? `${names.length} entries` : "no array"} for ${codes.length} securities — names are positional, so all of them were dropped; columns fall back to the security code.\n`);
270
+ return undefined;
271
+ }
272
+ let warned = false;
273
+ return names.map((name, i) => {
274
+ if (typeof name === "string" && name.trim() !== "")
275
+ return name;
276
+ // A blank or null name is a plausible gap for one security — fall back
277
+ // quietly. A non-string (an object, a number) means the field changed type,
278
+ // which is worth saying once.
279
+ if (typeof name !== "string" && name !== null && name !== undefined && !warned) {
280
+ warned = true;
281
+ process.stderr.write(`[gangtise] warning: securityNameList holds non-string entries; those columns fall back to the security code.\n`);
282
+ }
283
+ return codes[i];
284
+ });
285
+ }
286
+ /** The matrix endpoints cannot legitimately answer with a non-object payload:
287
+ * `indicator search` never reaches the flatteners (it prints the unwrapped list
288
+ * directly) and `raw call` bypasses them entirely, so there is no caller for
289
+ * which `null`, an array, or a foreign object is a valid cross-section /
290
+ * time-series / screener body. Checked here, BEFORE the envelope is discarded,
291
+ * because a `data: null` cannot carry the non-enumerable traceId — passing the
292
+ * envelope as details is the only way such a failure stays traceable. */
293
+ export function requireIndicatorMatrix(raw) {
294
+ const data = unwrapIndicatorData(raw);
295
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
296
+ throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, raw);
297
+ }
298
+ return data;
299
+ }
300
+ function assertValuesPresent(data, values) {
301
+ if (!Array.isArray(values)) {
302
+ throw new ApiError(`Indicator matrix shape mismatch: the response carries axis lists but no \`values\` array — the response layout may have changed`, undefined, undefined, data);
303
+ }
304
+ }
305
+ /** The matrix and its axis labels must agree on BOTH dimensions, or cells are
306
+ * dropped or misread. The 2026-08-01 revision transposed cross-section without a
307
+ * version marker, so a re-transpose has to fail loudly rather than silently
308
+ * relabel columns — with the caveat that this only catches a NON-SQUARE change.
309
+ * A 2×2 or 1×1 matrix keeps its dimensions when transposed and would still be
310
+ * read with the axes swapped; nothing in the payload distinguishes the two.
311
+ *
312
+ * Row length is checked exactly, not leniently: the server pads a row with
313
+ * `null` rather than truncating it — probed 2026-08-02 across A/HK/US, where a
314
+ * US security missing 3 of 4 indicators still came back with a full-length row,
315
+ * and a cross-market time series padded every security to the union of trading
316
+ * days (each market's own holidays land as `null`). A short or long row is
317
+ * therefore a structural change, not missing data.
318
+ *
319
+ * `data` rides along as ApiError details so the failure keeps the response's
320
+ * traceId — a shape mismatch is precisely the kind of thing support needs to
321
+ * trace, and without it the error reaches the user trace-less. */
322
+ function assertMatrixShape(data, values, rows, rowAxis, cols, colAxis) {
323
+ if (values.length !== rows) {
324
+ throw new ApiError(`Indicator matrix shape mismatch: got ${values.length} value rows for ${rows} ${rowAxis} — the response layout may have changed`, undefined, undefined, data);
325
+ }
326
+ for (const [i, row] of values.entries()) {
327
+ const width = Array.isArray(row) ? row.length : -1;
328
+ if (width !== cols) {
329
+ throw new ApiError(`Indicator matrix shape mismatch: value row ${i} has ${width < 0 ? "no array of" : String(width)} cells for ${cols} ${colAxis} — the response layout may have changed`, undefined, undefined, data);
330
+ }
331
+ }
332
+ }
82
333
  // Cross-section (and the screener, whose payload is the same shape plus a
83
334
  // per-indicator `field`): one row per security, one column per indicator.
84
335
  // `values` is [security][indicator], so security i's value for indicator j is
85
336
  // values[i][j]. There is no row-level date any more — the query date now lives
86
337
  // in each indicator's own parameters and may legitimately differ per column.
87
338
  export function flattenCrossSection(data, keyBy = "name") {
88
- if (!data || typeof data !== "object")
89
- return data;
339
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
340
+ throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, data);
341
+ }
90
342
  const d = data;
91
- const securityCode = asStringArray(d.securityCodeList);
92
- const indicators = asIndicatorMetaList(d.indicatorList);
93
- if (!Array.isArray(d.values) || !securityCode || !indicators)
94
- return data;
95
- const securityName = asStringArray(d.securityNameList);
343
+ assertMatrixPayload(data, d);
344
+ // Past this point the payload claims to be a matrix, so every axis it needs
345
+ // must actually be there. A `null` or absent axis is a broken response.
346
+ const securityCode = asIdentityArray(data, d.securityCodeList, "securityCodeList");
347
+ const indicators = asIndicatorMetaList(data, d.indicatorList);
348
+ assertAxis(data, securityCode, "securityCodeList");
349
+ assertAxis(data, indicators, "indicatorList");
350
+ assertValuesPresent(data, d.values);
351
+ assertMatrixShape(data, d.values, securityCode.length, "securities", indicators.length, "indicators");
352
+ const securityName = resolveSecurityNames(d.securityNameList, securityCode);
96
353
  const headers = indicatorHeaders(indicators, keyBy, CROSS_SECTION_COLUMNS);
97
354
  const list = securityCode.map((code, i) => {
98
- const row = { security: code, name: securityName?.[i] };
355
+ // Only create `name` when there IS one: an always-present `name: undefined`
356
+ // is invisible in JSON but renders as a real empty column in CSV/table.
357
+ const row = securityName ? { security: code, name: securityName[i] } : { security: code };
99
358
  const cells = rowOf(d.values, i);
100
359
  for (let j = 0; j < headers.length; j++) {
101
360
  row[headers[j]] = cells?.[j];
@@ -107,17 +366,66 @@ export function flattenCrossSection(data, keyBy = "name") {
107
366
  // Time-series: one row per date. Columns are the indicators (single-security
108
367
  // case) or the securities (single-indicator case) — exactly one dimension
109
368
  // varies, per the API contract. `values` stays a 2D [series][date] matrix.
110
- export function flattenTimeSeries(data, keyBy = "name") {
111
- if (!data || typeof data !== "object")
112
- return data;
369
+ //
370
+ // Axis rule, in priority order:
371
+ // 1. More than one indicator came back → multi-indicator × single-security.
372
+ // 2. More than one security came back → single-indicator × multi-security.
373
+ // This is what a sector ID produces: the request carries ONE universe entry
374
+ // and the server expands it into N constituents, so the request count says
375
+ // nothing about the axis.
376
+ // 3. Both are 1 → genuinely ambiguous, so fall back to what was REQUESTED:
377
+ // - a universe holding a sector ID always takes the security axis. The
378
+ // sector may expand to one constituent, or the rest may have been dropped
379
+ // for lack of data; either way the caller asked "which securities", and an
380
+ // indicator-named column would erase whose series this is.
381
+ // - otherwise the entry count decides. The server drops securities with no
382
+ // data at all, so a two-security request that comes back with one must
383
+ // still be labelled by security (probed 2026-08-02: `finc_pe_ttm` over
384
+ // 600519.SH + 09992.HK).
385
+ export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
386
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
387
+ throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, data);
388
+ }
113
389
  const d = data;
114
- const dates = asStringArray(d.dates);
115
- const securityCode = asStringArray(d.securityCodeList);
116
- const indicators = asIndicatorMetaList(d.indicatorList);
117
- if (!Array.isArray(d.values) || !dates || !securityCode || !indicators)
118
- return data;
119
- const securityName = asStringArray(d.securityNameList);
120
- const seriesAreIndicators = securityCode.length <= 1;
390
+ assertMatrixPayload(data, d);
391
+ const dates = asIdentityArray(data, d.dates, "dates");
392
+ const securityCode = asIdentityArray(data, d.securityCodeList, "securityCodeList");
393
+ const indicators = asIndicatorMetaList(data, d.indicatorList);
394
+ assertAxis(data, dates, "dates");
395
+ assertAxis(data, securityCode, "securityCodeList");
396
+ assertAxis(data, indicators, "indicatorList");
397
+ assertValuesPresent(data, d.values);
398
+ const securityName = resolveSecurityNames(d.securityNameList, securityCode);
399
+ // The API permits multi-indicator × single-security OR single-indicator ×
400
+ // multi-security, never both — the server rejects such a REQUEST with 100003.
401
+ // A RESPONSE carrying both axes plural is therefore unattributable: whichever
402
+ // axis becomes the columns, the other one's identity is silently discarded
403
+ // (probed 2026-08-02: 2×2 rendered as 收盘价/成交量 with both securities gone,
404
+ // and droppedFromMatrix sees nothing missing so it would not even flag).
405
+ // Data with only one identity axis is unattributable in the other direction:
406
+ // `securityCodeList: []` alongside a populated matrix leaves every row
407
+ // belonging to no security at all, and the row/column counts still line up so
408
+ // no other guard notices.
409
+ // Without this, dates-with-no-series still passes the later shape check (0 rows
410
+ // for 0 series) and emits one row per date carrying nothing but the date. A
411
+ // genuine no-data answer empties `dates` too (probed 2026-08-02), so dates
412
+ // alongside a missing axis is always a broken response.
413
+ if (dates.length > 0 && (securityCode.length === 0 || indicators.length === 0)) {
414
+ throw new ApiError(`Indicator matrix shape mismatch: the time-series response carries ${dates.length} dates but ${securityCode.length} securities and ${indicators.length} indicators — the data could not be attributed`, undefined, undefined, data);
415
+ }
416
+ if (securityCode.length > 1 && indicators.length > 1) {
417
+ throw new ApiError(`Indicator matrix shape mismatch: the time-series response carries ${securityCode.length} securities AND ${indicators.length} indicators, which the endpoint does not support — one of the two identities would be lost`, undefined, undefined, data);
418
+ }
419
+ // A universe entry without a `.` is a sector ID — the server expands it, so
420
+ // neither its presence nor the entry count says anything about the axis.
421
+ const requested = requestedUniverse === undefined ? undefined : [...new Set(requestedUniverse)];
422
+ const hasSector = requested?.some((entry) => !entry.includes(".")) ?? false;
423
+ const seriesAreIndicators = indicators.length > 1 ? true
424
+ : securityCode.length > 1 ? false
425
+ : hasSector ? false
426
+ : (requested?.length ?? securityCode.length) <= 1;
427
+ const seriesCount = seriesAreIndicators ? indicators.length : securityCode.length;
428
+ assertMatrixShape(data, d.values, seriesCount, seriesAreIndicators ? "indicators" : "securities", dates.length, "dates");
121
429
  // Map over securityCode rather than securityNameList directly: a response that
122
430
  // omits the names must still yield one header per security (falling back to
123
431
  // the code), not zero columns.
@@ -28,10 +28,16 @@ export async function printData(data, format, output, cache) {
28
28
  const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
29
29
  process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
30
30
  }
31
- // Partial results (failed pages/shards) exit with code 3: the table/csv/jsonl
31
+ // Incomplete or untrustworthy results exit with code 3: the table/csv/jsonl
32
32
  // renderers only emit the rows, so without a distinct exit code a script or AI
33
- // consumer cannot tell a partial export from a complete one.
34
- if (meta.partial === true) {
33
+ // consumer cannot tell such an export from a clean one.
34
+ // - `partial`: rows are missing (failed pages/shards, a row cap, or an EDE
35
+ // response that dropped whole securities/indicators).
36
+ // - `unreliable`: the rows that ARE here may hold wrong values — currently
37
+ // only the screener's duplicate-indicator defect, where every binding of a
38
+ // repeated code is resolved from the EARLIEST date among them, so the one
39
+ // surviving number need not belong to the variable it is labelled with.
40
+ if (meta.partial === true || meta.unreliable === true) {
35
41
  process.exitCode = 3;
36
42
  }
37
43
  }
@@ -104,11 +104,12 @@ const RETRYABLE_API_CODES = new Set(["999999"]);
104
104
  // - 140002 PROCESSING_FAILED: the async *-check endpoints (get-content) declare no
105
105
  // retry policy, so a 140002@500 would be retried 2× by the default policy BEFORE
106
106
  // asyncContent's FAILED_CODES gets to call it terminal — that guard sits above
107
- // client.call's withRetry and cannot see the retries. 140002 means "generation
108
- // failed" (terminal by definition) and only those async endpoints can return it,
109
- // so a blanket rule is safe and skips the wasted retries. Server still emits the
110
- // legacy 410111 today (410111@400 isn't retryable anyway); this is a forward guard
111
- // for the documented 140002@500.
107
+ // client.call's withRetry and cannot see the retries. 140002 is terminal in both
108
+ // of its meanings — async "generation failed", and (probed 2026-08-02) the EDE
109
+ // indicator endpoints' parameter/expression errors, e.g. an out-of-range enum or
110
+ // a malformed screener expression — so the blanket rule stays correct and skips
111
+ // the wasted retries. Server still emits the legacy 410111 today (410111@400
112
+ // isn't retryable anyway); this is a forward guard for the documented 140002@500.
112
113
  const TERMINAL_API_CODES = new Set(["999011", "140002"]);
113
114
  // Connect-phase / DNS failures: the request provably never reached the server, so a
114
115
  // replay cannot double-execute (or double-bill) anything even under "no-replay".
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.30.0";
2
+ export const CLI_VERSION = "0.31.0";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.30.0"
3
+ version: "0.31.0"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
@@ -36,7 +36,7 @@ description: |-
36
36
  - Token 失效(`0000001008` / `999002`,含已废弃的 `8000014`/`8000015`)→ 自动重新登录并重试一次;凭证错 `999011` → **不重试**(AK/SK 不对不会自己好),查环境变量
37
37
  8. **参数命名差异**:Insight/Quote/Vault 用 `--security`,Fundamental/AI 用 `--security-code`(例外:`ai stock-summary` 用 `--security`,`ai security-clue` 用 `--gts-code`)。
38
38
  9. **调试**:`--verbose` 或 `GANGTISE_VERBOSE=1` 打印每个请求的耗时/字节数到 stderr。
39
- 10. **`--field` 字段名必须核对,不确定就别传**(返回全量最稳):`quote realtime` / `fundamental main-business` / `valuation-analysis` 遇到不存在的字段名时,上游只丢**值**、字段名照请求**回显**,按位置拍平会把值贴到错误的字段上(实测 realtime 传 `close`——它没有这个字段——换手率 28.5573 被贴成 `close`,茅台真实价 1297.41)。v0.28.3 起 CLI 检测到长度不匹配直接报错(退出码 1):**带 `--field` 的命令看到这个报错,先去 `references/fields.md` 核对字段名**(没有 `--field` 的命令如 `alternative edb-data` 报此错则是上游响应结构异常,报障时若报错末尾附了 `(trace …)` 就一并带上)。另:realtime **无 `close`**(用 `latestPrice`)、**无市值**(总市值走 `indicator cross-section --indicator qte_mkt_cptl`,仅 A 股)。
39
+ 10. **`--field` 字段名必须核对,不确定就别传**(返回全量最稳):`quote realtime` / `fundamental main-business` / `valuation-analysis` 遇到不存在的字段名时,上游只丢**值**、字段名照请求**回显**,按位置拍平会把值贴到错误的字段上(实测 realtime 传 `close`——它没有这个字段——换手率 28.5573 被贴成 `close`,茅台真实价 1297.41)。v0.28.3 起 CLI 检测到长度不匹配直接报错(退出码 1):**带 `--field` 的命令看到这个报错,先去 `references/fields.md` 核对字段名**(没有 `--field` 的命令如 `alternative edb-data` 报此错则是上游响应结构异常,报障时若报错末尾附了 `(trace …)` 就一并带上)。另:realtime **无 `close`**(用 `latestPrice`)、**无市值**(总市值走 `indicator cross-section --indicator qte_mkt_cptl`,2026-08-03 起 A/港/美股均有数)。
40
40
 
41
41
  ## 工作流(3 步)
42
42
 
@@ -166,14 +166,14 @@ description: |-
166
166
  - 港股代码用在 `insight foreign-opinion --security` 还是 `quote day-kline-hk --security`?前者要"境外"格式(`UBER.N`),后者要 `.HK`
167
167
  - "成分股" → 题材深度(分组/重点标记/纳入理由)走 `alternative concept-securities`;板块(行业/概念分类树,纯代码名单)走 `reference sector-constituents`
168
168
  - **证券基本面 / 指标先按任务形态路由,不是搜到 EDE 就一律走 EDE**:
169
- - 单证券先优先对应 `fundamental` 专用命令(财务、估值、盈利预测、股东、主营或完整三大报表,多数免费 / 低价)。其中 `valuation-analysis` / `earning-forecast` 实测仅支持 A 股;港 / 美股的估值历史分位、盈利预测、以及 PE/PB 等核心估值(EDE 也仅 A 股)当前 CLI 均无可用接口,如实说明不支持、勿用别的语义顶替
169
+ - 单证券先优先对应 `fundamental` 专用命令(财务、估值、盈利预测、股东、主营或完整三大报表,多数免费 / 低价)。其中 `valuation-analysis` / `earning-forecast` 实测仅支持 A 股,港 / 美股的**估值历史分位**与**盈利预测**当前无可用口径。但**估值指标本身别照抄旧结论**:2026-08-03 实测 `finc_pe_ttm` 港股已有数、`qte_mkt_cptl`/`shr_tot` 港美股都已有数。⚠️ **凡「仅 A 股」「无数据」这类否定结论都只是某时点抽查**,服务端在持续补数据;负面结论过期不会报错、只会让你白白拒掉一个能跑的查询。**一律以当次 `scopeList` + 抽查为准**
170
170
  - 多证券批量取一组**已实现**财务 / 估值指标 → 优先 `indicator search` 后用 EDE 一次拉取,替代逐只循环;单日或同一报告期横向比较用 `cross-section`,区间走势用 `time-series`(后者不能多指标 × 多证券同时)。**批量按 code 回填加 `--key-by code`**(列头用 `indicatorCode`,防同名指标碰撞 + 服务端重排列序导致的错位)
171
- - 始终排除 EDE:A股盈利预测 / 一致预期(含预测 EPS)→ `fundamental earning-forecast`;A股估值历史分位 → `fundamental valuation-analysis`;开高低收 / 成交量等行情与 K 线 → `quote`;单证券完整报表 → 对应三大报表命令。**例外:总市值只有 EDE 有**——`quote realtime` / `day-kline` 都不返回市值,走 `indicator cross-section --indicator qte_mkt_cptl`(仅 A 股,默认单位「元」,用 `--scale` 缩放)。EDE 搜到的基本 / 稀释 EPS 是已实现值,**不能冒充预测 EPS**;港 / 美股缺少上述专用能力时应如实说明不支持,不能用别的语义代替
171
+ - 始终排除 EDE:A股盈利预测 / 一致预期(含预测 EPS)→ `fundamental earning-forecast`;A股估值历史分位 → `fundamental valuation-analysis`;开高低收 / 成交量等行情与 K 线 → `quote`;单证券完整报表 → 对应三大报表命令。**例外:总市值只有 EDE 有**——`quote realtime` / `day-kline` 都不返回市值,走 `indicator cross-section --indicator qte_mkt_cptl`(2026-08-03 起 A/港/美股均有数,默认单位「元」,用 `--scale` 缩放)。EDE 搜到的基本 / 稀释 EPS 是已实现值,**不能冒充预测 EPS**;港 / 美股缺少上述专用能力时应如实说明不支持,不能用别的语义代替
172
172
  - EDE 取数前必须用 `search --format json` 同时核对:`indicatorName` + `description` 语义准确、`scopeList` 覆盖全部目标市场 / 证券类型、`parameterList` 必填参数与枚举可满足;`scopeList` 缺失 / `null` / 空或任一项不符,都视为无法证明覆盖并回退专用接口。专用接口也不覆盖目标市场时,说明当前不可用,不要硬调。`scopeList` 按指标各不相同,不能因 EDE 服务支持 A / 港 / 美股就假定某个指标三市场都覆盖
173
173
  - `indicator search` 免费,`cross-section` / `time-series` 按单元格计费;除多证券批量的效率收益外,仍优先免费 / 低价的 `quote` 或 `fundamental`
174
174
  - 行业 / 宏观指标(空调销量、社融等,无证券维度)走 `alternative edb-*`(EDB),不要与证券级 EDE 混用
175
- - EDE 单元格级缺值返回 `null` 且保留证券行;**整个查询无数据返回空表**(2026-08-01 起不再报 `999999`)——所以**参数写错也表现为空表**,拿到空表先查参数名和日期语义,别当成真没数据。日期语义按指标分三类:财务报表指标=报告期末(可为非交易日)、`finc_pe_ttm` 等日频估值=最新交易日、`finc_pb_mrq`(MRQ) 等=最近报告期末(交易日取 `null`);混合取数按各自有效日期分次 `cross-section` 再按证券合并,别塞进同一个 `--date`。详见 `references/commands/indicator.md`
176
- - **EDE 指标参数名一律以 `indicator search --format json` 的 `parameterList` 为准**,不要凭记忆或照抄示例:服务端会改参数名(`adjustmentType` → `adjustType`,2026-08-01),**传错名是静默忽略、退回默认值**而不是报错——实测 `adjustmentType=3` 返回的是不复权价,看着正常实则错数据。`--indicator` / `--security` 支持板块 ID(`reference sector-search` 的 10 位 `sectorId`)。⚠️ 根级 `--scale` 会污染不声明 `scale` 的指标(`qte_close` 被 `--scale 8` 缩成 `0`),价格与金额混查时改用 `--indicator-param "code:scale=8"`
175
+ - EDE 缺数据分四档,**别只认 `null`**:①部分缺 → 单元格 `null`(行列都在),**退出码 0**;②某指标对所有证券都无数据 → 该指标**整列消失**;③某证券对所有指标都无数据 → 该证券**整行消失**——②③ CLI 会标 `partial: true` + `omittedIndicators`/`omittedSecurities` 并**退出码 3**(**`screener` 例外见下**)(`--key-by code` 回填时 key 会直接不存在,务必先看这两个字段);④整个查询无数据 → **空表**(2026-08-01 起不再报 `999999`),**退出码 0**、不标 partial(什么都没被丢,就是没数据),但 stderr 会提醒这也可能是参数名写错。⚠️ 由此推出一个不直觉但自洽的结果:**同一个 scope 落空,单查是 0、混查是 3**(`finc_pb_mrq` 只查 `09992.HK` → 整体全空 → 0;和茅台一起查 → 泡泡玛特整行被丢 → 3)。全空时没有任何证据能证明「被丢了」,只有存在对照物时才有——所以**要验证某标的是否被覆盖,就把它和一个已知有数的标的一起查**。**参数写错表现为空表**,拿到空表先查参数名和日期语义,别当成真没数据。日期语义按指标分两类:财务报表指标=报告期末(可为非交易日)、`finc_pe_ttm` / `finc_pb_mrq` 等日频估值=最新交易日(⚠️ MRQ 口径 2026-08-02 已改为日频,旧文档「只在报告期末打值」已作废,照旧写法会取到几个月前的陈值);混合取数按各自有效日期分次 `cross-section` 再按证券合并,别塞进同一个 `--date`。详见 `references/commands/indicator.md`
176
+ - **EDE 指标参数名一律以 `indicator search --format json` 的 `parameterList` 为准**,不要凭记忆或照抄示例:服务端会改参数名(`adjustmentType` → `adjustType`,2026-08-01),**传错名是静默忽略、退回默认值**而不是报错——实测 `adjustmentType=3` 返回的是不复权价,看着正常实则错数据。`--security` 支持板块 ID(`reference sector-search` 的 10 位 `sectorId`;`--indicator` 只收指标编码)。⚠️ 根级 `--scale` 会污染不声明 `scale` 的指标(`qte_close` 被 `--scale 8` 缩成 `0`),价格与金额混查时改用 `--indicator-param "code:scale=8"`
177
177
  - "业绩点评"双义消歧:**检索已有**(研报/纪要里的业绩点评内容)走 `insight ... list --llm-tag earningsReview`(0.1/条);**AI 现生成**一份走 `ai earnings-review`(异步、50/次)。不确定问一句
178
178
 
179
179
  ## 公司名 → 证券代码
@@ -280,7 +280,7 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
280
280
  | ✅ `0000001008` | Token 服务端失效(他处登录挤掉)——**旧码未切,token 自愈依赖它** | **强制重新登录并重试一次** | 无 AK/SK 时无法自愈,提示重新登录 |
281
281
  | ✅ `0000001007` | 请求未携带 Bearer token | — | 检查 `GANGTISE_TOKEN` / AK/SK 是否已 export |
282
282
  | ✅ `900002` | **请求方法不正确**(msg「请求类型有误」,HTTP 405)——旧文档写作"缺少 uid"是错的 | — | `raw call` 时确认该 endpoint 是 GET 还是 POST |
283
- | `410106` / 缺参 | `indicator` 缺必填参数(msg 直接指明,如「必填参数 periodNum 不能为空」;HTTP 500 故 CLI 重试 ×2) | **自动重试 ×2** | 读 `indicator search --format json` 的 `parameterList` 补 `required:true` 参数 |
283
+ | ✅ `140002` | 终态参数错:AI 异步生成失败,或 `indicator` 的指标必填参数缺失 / 枚举越界 / 表达式语法错(实测 2026-08-02) | **不重试**(终态码) | 按 msg 改参数重提;EDE 的参数名与枚举读 `indicator search --format json` 的 `parameterList` |
284
284
 
285
285
  **⚠️ 实测发现的坑(都是"不报错"型,最难发现)**
286
286
  - 🔴 **日期只写 `YYYY-MM-DD`、时间只写 `YYYY-MM-DD HH:mm:ss`(或 10/13 位时间戳);CLI v0.28.0 起 date 与 datetime 两类、含所有 insight/vault 透传参数都本地拦截**。服务端对「年在后」格式**日月顺序随分隔符翻转**且静默误解析(HTTP 200、不回显实际用的日期):`07/01/2026`(斜杠)读成 **2026-01-07**、`07-01-2026`(横杠)读成 **2026-07-01**,差半年。实测 `insight research list --start-time`:`07/01/2026` 命中 1562 条、`07-01-2026` 命中 210 条(分别 = 标准 `2026-01-07` / `2026-07-01`);`quote day-kline`/`kline-hk`/`kline-us`/`index`、`fundamental balance-sheet` 同理。v0.28.0 前透传命令(research/summary/announcement-hk/us/vault/minute-kline 等)**静默放行**,且同值在本地转时间戳的 `announcement`(A 股)与透传的 hk/us 之间还会差半年、都 exit 0。现在全部在发请求前报 `ValidationError`,**但绕过 CLI 直连接口务必自己保证格式**
@@ -315,7 +315,11 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
315
315
 
316
316
  **其他场景**:
317
317
  - CLI 未安装 → `npm install -g gangtise-openapi-cli`
318
- - **退出码 3 = 部分结果**:翻页/K线分片有页失败、或服务端返回行数与 `total` 矛盾(提前短页)时,已取到的数据保留——stderr 有 warning,`--format json` 可见 `partial: true`(页失败另有 `failedPages`;分片失败为 `failedShards`、分片撞行数上限为 `truncatedShards`,均带具体日期区间可定向缩窗补拉);table/csv/jsonl 只有数据行、看不出缺失。拿部分数据继续前必须告知用户缺了哪段
318
+ - **退出码 3 = 结果不完整或不可信**(两种语义,都保留已取到的数据;stderr 有 warning,`--format json` 才看得见标记,table/csv/jsonl 只有数据行、看不出问题):
319
+ - **`partial: true` = 少了数据**。翻页/K线分片有页失败、服务端返回行数与 `total` 矛盾(提前短页)、或 **EDE 整指标/整证券被服务端略过**。附带定位字段:页失败 `failedPages`;分片失败 `failedShards`、分片撞行数上限 `truncatedShards`(均带日期区间可缩窗补拉);EDE 为 `omittedIndicators` / `omittedSecurities`(那几个 code 一个值都没有,先查 `scopeList` 覆盖和日期语义)
320
+ - **`unreliable: true` = 在场的值不可信**。目前只有 EDE 条件选股把同一指标绑到多个变量时会出现,附 `duplicatedIndicators`。服务端缺陷(2026-08-03 实测):这些绑定**全部按其中最早的那个日期取数**,该值落到它们的第一列、其余列为 `null`——所以**活下来的那个数字未必属于它标注的变量**(声明 07-31 的变量拿到了 07-30 的价)。**整份结果都不可信**:显示的值可能张冠李戴,命中的证券集合也不可信(针对 null 变量的条件等于没筛),同一请求还有约 1/3 概率返回空集。**不能拿来下任何结论**,拆成多次调用重跑
321
+ - **`screener` 的缺列按表达式的布尔结构判**:把缺列的变量当作「无法求值」,看整个表达式**是否还有一条能成立的分支**——`A && B` 要两边都可求值,`A || B` 只要一边。**一条分支都不剩 → 退出码 1、不输出任何结果**(返回的行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过);**还有分支可求值,或缺的只是未参与表达式的辅助变量 → `partial` + 退出码 3**。所以 `F1 || F2` 缺 F1 是降级(行可能靠 F2 正当命中),缺两个则是致命;`F1 && (F2 || F3)` 缺 F1 仍是致命(F1 是必选合取项)
322
+ - 拿到 3 就必须告知用户缺了哪段 / 哪些值不可信,不能当成功静默继续
319
323
  - 空结果(list 为空数组) → 建议扩大时间范围、换关键词、去掉部分筛选
320
324
  - 模糊公司名匹配多只("平安" → 中国平安 / 平安银行 / ...) → 列出让用户选
321
325
  - 下载文件路径冲突 → 询问覆盖