gangtise-openapi-cli 0.30.1 → 0.32.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);
@@ -87,16 +115,116 @@ function indicatorHeaders(indicators, keyBy, reserved) {
87
115
  return buildHeaders(bases.map((base, i) => (base !== undefined && repeated.has(base) ? `${base} (${fields[i] ?? i})` : base)), fields, reserved);
88
116
  }
89
117
  const CROSS_SECTION_COLUMNS = ["security", "name"];
90
- /** What the caller asked for that the response does not contain. The server
91
- * does NOT pad missing data with `null`: an indicator that is empty for every
92
- * security disappears from `indicatorList`, and a security that is empty for
93
- * every indicator disappears from `securityCodeList` (probed 2026-08-02 — three
94
- * indicators over 09992.HK came back with one, two securities over
95
- * `qte_mkt_cptl` came back with one). Only partial gaps become `null`.
118
+ /** A response carrying neither securities nor indicators: the query as a whole
119
+ * resolved to nothing. This is NOT a dropped axis, so it must not be reported as
120
+ * partial — calling every requested code "omitted" would be false metadata, the
121
+ * diff against the request is total by construction.
122
+ *
123
+ * What it MEANS changed on 2026-08-07: a genuine no-data answer (non-trading
124
+ * date, uncovered market, future date) now comes back as a null cell with its
125
+ * row and column intact, so an all-empty matrix no longer means "no data". It
126
+ * now means nothing in the request RESOLVED — every security code or every
127
+ * indicator code was unrecognised — or a parameter name is wrong. The caller's
128
+ * stderr note says exactly that.
129
+ *
130
+ * The check covers every STRUCTURAL array, not just the two axis lists, because
131
+ * accepting anything looser would let a malformed payload — `values: null`, a
132
+ * missing `values`, or dates with no matrix — pass as "legitimately empty" and
133
+ * exit 0, which is precisely the protocol regression this release's shape guards
134
+ * exist to catch. Required empty: `securityCodeList`, `indicatorList`, `values`;
135
+ * plus `dates` when present. Probed 2026-08-02 (back when this shape still meant
136
+ * "no data"): a time-series all-empty answer is five empty arrays, a
137
+ * cross-section one is four (it carries no `dates` key at all), hence "absent or
138
+ * empty" rather than a flat count. The STRUCTURE is unchanged; only its meaning
139
+ * moved — since 2026-08-07 it signals an unresolved axis, not missing data.
140
+ *
141
+ * `securityNameList` is deliberately NOT checked: it holds display labels, not
142
+ * structure, so a `null` there cannot misalign anything — and rejecting it would
143
+ * re-introduce the false-partial this function exists to prevent. */
144
+ export function isEmptyMatrix(data) {
145
+ if (!data || typeof data !== "object")
146
+ return false;
147
+ const d = data;
148
+ if (asStringArray(d.securityCodeList)?.length !== 0)
149
+ return false;
150
+ if (!Array.isArray(d.indicatorList) || d.indicatorList.length !== 0)
151
+ return false;
152
+ if (!Array.isArray(d.values) || d.values.length !== 0)
153
+ return false;
154
+ // `dates` is time-series only; when present it must be empty too.
155
+ return d.dates === undefined || (Array.isArray(d.dates) && d.dates.length === 0);
156
+ }
157
+ /** Validate the variable bindings a screener answered with, returning the bound
158
+ * variables that are merely missing (see below).
159
+ *
160
+ * The screener answers with the variable each column was requested under, and
161
+ * that binding is the ONLY thing tying a column back to the filter it came from
162
+ * — nothing else in the payload can catch it going wrong. A swapped or unknown
163
+ * `field` renders as a perfectly ordinary result (probed 2026-08-02: a response
164
+ * labelling a requested `F1` as `F9` printed a normal table at exit 0), which
165
+ * would make every screening decision downstream unfounded.
96
166
  *
97
- * That is the dangerous shape: `--key-by code` batch mapping finds no key at
98
- * all rather than a null, and a cross-market pull quietly returns fewer rows
99
- * than requested with exit code 0.
167
+ * Every returned entry must therefore name a REQUESTED variable and carry that
168
+ * variable's code, and no variable may appear twice. A wholly empty result binds
169
+ * nothing and is left alone. */
170
+ export function checkScreenerBindings(data, requested, expression) {
171
+ if (!data || typeof data !== "object")
172
+ return [];
173
+ const d = data;
174
+ // A result with no securities matched nothing and binds nothing — the
175
+ // canonical empty answer, left alone.
176
+ if (!Array.isArray(d.securityCodeList) || d.securityCodeList.length === 0)
177
+ return [];
178
+ const indicators = Array.isArray(d.indicatorList) ? d.indicatorList : [];
179
+ const wanted = new Map(requested.map((binding) => [binding.field, binding.indicatorCode]));
180
+ const seen = new Set();
181
+ for (const [i, meta] of indicators.entries()) {
182
+ const field = optionalString(meta?.field);
183
+ const code = optionalString(meta?.code);
184
+ if (!field || !wanted.has(field)) {
185
+ 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);
186
+ }
187
+ if (wanted.get(field) !== code) {
188
+ 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);
189
+ }
190
+ if (seen.has(field)) {
191
+ throw new ApiError(`Screener binding mismatch: variable ${field} appears twice in the response — the mapping back to a filter is ambiguous`, undefined, undefined, data);
192
+ }
193
+ seen.add(field);
194
+ }
195
+ // Securities came back, so the filter ran. A column vanishing is never "it got
196
+ // filtered out" — filtering removes securities (rows), never indicators
197
+ // (columns). Since 2026-08-07 a covered-but-empty indicator keeps a null
198
+ // column, so an absent column means the server did not resolve that indicator
199
+ // code at all. What that implies depends on how the expression combines terms:
200
+ //
201
+ // The answer follows the expression's BOOLEAN STRUCTURE, not the mere presence
202
+ // of a `||` — see screenerExpressionIsEvaluable. If no branch survives the
203
+ // missing columns, the rows cannot be shown to satisfy anything and the result
204
+ // is fatal; if some branch still could have matched, the absence proves nothing
205
+ // wrong and it degrades to partial.
206
+ const missing = [...wanted.keys()].filter((field) => !seen.has(field));
207
+ if (!screenerExpressionIsEvaluable(expression, seen)) {
208
+ const absent = [...new Set(screenerExpressionFields(expression))].filter((field) => !seen.has(field));
209
+ 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);
210
+ }
211
+ // Whatever survives is an output column that went missing: information lost,
212
+ // correctness intact.
213
+ return missing;
214
+ }
215
+ /** What the caller asked for that the response does not contain.
216
+ *
217
+ * The server used to drop any axis it had no DATA for, which made this a
218
+ * coverage check. Re-probed 2026-08-08: a coverage gap is now padded with `null`
219
+ * and keeps its row and column, down to the 1×1 case (`finc_pb_mrq` × 09992.HK,
220
+ * `mgn_bal` × 00700.HK — null, present, alone in the request). What still
221
+ * disappears is a code the server cannot RESOLVE: an unknown indicator code, or
222
+ * a security code with the wrong market suffix (`AAPL.US` vanishes, `AAPL.O`
223
+ * returns).
224
+ *
225
+ * So the remaining shape is the dangerous one: a misspelled code is invisible
226
+ * otherwise — `--key-by code` batch mapping finds no key at all rather than a
227
+ * null, and the pull quietly returns fewer rows than requested with exit code 0.
100
228
  *
101
229
  * Universe entries with no `.` are skipped — those are sector IDs, which the
102
230
  * server expands into constituents, so their absence from the response is
@@ -107,20 +235,108 @@ export function droppedFromMatrix(data, requestedSecurities, requestedIndicators
107
235
  return empty;
108
236
  const d = data;
109
237
  const returnedSecurities = new Set(asStringArray(d.securityCodeList) ?? []);
110
- const returnedIndicators = new Set((asIndicatorMetaList(d.indicatorList) ?? []).map((meta) => optionalString(meta.code)));
238
+ const returnedIndicators = new Set((Array.isArray(d.indicatorList) ? d.indicatorList : []).map((meta) => optionalString(meta?.code)));
111
239
  return {
112
240
  securities: requestedSecurities.filter((code) => code.includes(".") && !returnedSecurities.has(code)),
113
241
  indicators: requestedIndicators.filter((code) => !returnedIndicators.has(code)),
114
242
  };
115
243
  }
116
- /** The matrix and its axis labels must agree, or every cell is off by a row.
117
- * The 2026-08-01 revision transposed cross-section without a version marker, so
118
- * a future re-transpose has to fail loudly instead of silently relabelling
119
- * columns. Rows are compared, not cells: the server legitimately returns short
120
- * rows when a trailing indicator has no data anywhere. */
121
- function assertRowCount(rows, expected, axis) {
122
- if (rows.length !== expected) {
123
- throw new ApiError(`Indicator matrix shape mismatch: got ${rows.length} value rows for ${expected} ${axis} — the response layout may have changed`, undefined);
244
+ /** True only when the payload carries NONE of the EDE matrix keys — a foreign
245
+ * shape. Nothing legitimately reaches the flatteners in that state — `indicator
246
+ * search` prints its unwrapped list directly and `raw call` bypasses them — so
247
+ * this is a protocol failure, not a payload to hand back: returning it would
248
+ * print the raw envelope and exit 0, indistinguishable from success. */
249
+ function assertMatrixPayload(data, d) {
250
+ const bare = d.securityCodeList === undefined && d.securityNameList === undefined
251
+ && d.indicatorList === undefined && d.dates === undefined && d.values === undefined;
252
+ if (bare) {
253
+ throw new ApiError("Indicator matrix shape mismatch: the response carries none of the matrix fields — the response layout may have changed", undefined, undefined, data);
254
+ }
255
+ }
256
+ function assertAxis(data, axis, key) {
257
+ if (axis === undefined) {
258
+ 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);
259
+ }
260
+ }
261
+ /** Resolve the display names for a security axis, degrading instead of failing.
262
+ *
263
+ * Names are consumed POSITIONALLY, so a list that does not line up cannot be
264
+ * trusted at all: `["泡泡玛特"]` against `["600519.SH","09992.HK"]` labels 茅台's
265
+ * series 泡泡玛特, and a `[null]` element used to render a column literally
266
+ * headed `"null"` (both probed 2026-08-02). Neither may reach the output.
267
+ *
268
+ * But a name is a LABEL, not structure — `securityCodeList` already carries
269
+ * identity, and every header falls back to the code. So an anomaly here drops
270
+ * the names and keeps the values, rather than killing an EDE command whose
271
+ * numbers are all correct. That is a deliberate asymmetry with the other guards
272
+ * in this module: they protect against MISATTRIBUTED VALUES and must be fatal;
273
+ * this one protects a caption. */
274
+ function resolveSecurityNames(names, codes) {
275
+ if (names === undefined || names === null)
276
+ return undefined;
277
+ if (!Array.isArray(names) || names.length !== codes.length) {
278
+ 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`);
279
+ return undefined;
280
+ }
281
+ let warned = false;
282
+ return names.map((name, i) => {
283
+ if (typeof name === "string" && name.trim() !== "")
284
+ return name;
285
+ // A blank or null name is a plausible gap for one security — fall back
286
+ // quietly. A non-string (an object, a number) means the field changed type,
287
+ // which is worth saying once.
288
+ if (typeof name !== "string" && name !== null && name !== undefined && !warned) {
289
+ warned = true;
290
+ process.stderr.write(`[gangtise] warning: securityNameList holds non-string entries; those columns fall back to the security code.\n`);
291
+ }
292
+ return codes[i];
293
+ });
294
+ }
295
+ /** The matrix endpoints cannot legitimately answer with a non-object payload:
296
+ * `indicator search` never reaches the flatteners (it prints the unwrapped list
297
+ * directly) and `raw call` bypasses them entirely, so there is no caller for
298
+ * which `null`, an array, or a foreign object is a valid cross-section /
299
+ * time-series / screener body. Checked here, BEFORE the envelope is discarded,
300
+ * because a `data: null` cannot carry the non-enumerable traceId — passing the
301
+ * envelope as details is the only way such a failure stays traceable. */
302
+ export function requireIndicatorMatrix(raw) {
303
+ const data = unwrapIndicatorData(raw);
304
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
305
+ 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);
306
+ }
307
+ return data;
308
+ }
309
+ function assertValuesPresent(data, values) {
310
+ if (!Array.isArray(values)) {
311
+ 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);
312
+ }
313
+ }
314
+ /** The matrix and its axis labels must agree on BOTH dimensions, or cells are
315
+ * dropped or misread. The 2026-08-01 revision transposed cross-section without a
316
+ * version marker, so a re-transpose has to fail loudly rather than silently
317
+ * relabel columns — with the caveat that this only catches a NON-SQUARE change.
318
+ * A 2×2 or 1×1 matrix keeps its dimensions when transposed and would still be
319
+ * read with the axes swapped; nothing in the payload distinguishes the two.
320
+ *
321
+ * Row length is checked exactly, not leniently: the server pads a row with
322
+ * `null` rather than truncating it — probed 2026-08-02 across A/HK/US, where a
323
+ * US security missing 3 of 4 indicators still came back with a full-length row,
324
+ * and a cross-market time series padded every security to the union of trading
325
+ * days (each market's own holidays land as `null`). A short or long row is
326
+ * therefore a structural change, not missing data.
327
+ *
328
+ * `data` rides along as ApiError details so the failure keeps the response's
329
+ * traceId — a shape mismatch is precisely the kind of thing support needs to
330
+ * trace, and without it the error reaches the user trace-less. */
331
+ function assertMatrixShape(data, values, rows, rowAxis, cols, colAxis) {
332
+ if (values.length !== rows) {
333
+ throw new ApiError(`Indicator matrix shape mismatch: got ${values.length} value rows for ${rows} ${rowAxis} — the response layout may have changed`, undefined, undefined, data);
334
+ }
335
+ for (const [i, row] of values.entries()) {
336
+ const width = Array.isArray(row) ? row.length : -1;
337
+ if (width !== cols) {
338
+ 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);
339
+ }
124
340
  }
125
341
  }
126
342
  // Cross-section (and the screener, whose payload is the same shape plus a
@@ -129,18 +345,25 @@ function assertRowCount(rows, expected, axis) {
129
345
  // values[i][j]. There is no row-level date any more — the query date now lives
130
346
  // in each indicator's own parameters and may legitimately differ per column.
131
347
  export function flattenCrossSection(data, keyBy = "name") {
132
- if (!data || typeof data !== "object")
133
- return data;
348
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
349
+ 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);
350
+ }
134
351
  const d = data;
135
- const securityCode = asStringArray(d.securityCodeList);
136
- const indicators = asIndicatorMetaList(d.indicatorList);
137
- if (!Array.isArray(d.values) || !securityCode || !indicators)
138
- return data;
139
- assertRowCount(d.values, securityCode.length, "securities");
140
- const securityName = asStringArray(d.securityNameList);
352
+ assertMatrixPayload(data, d);
353
+ // Past this point the payload claims to be a matrix, so every axis it needs
354
+ // must actually be there. A `null` or absent axis is a broken response.
355
+ const securityCode = asIdentityArray(data, d.securityCodeList, "securityCodeList");
356
+ const indicators = asIndicatorMetaList(data, d.indicatorList);
357
+ assertAxis(data, securityCode, "securityCodeList");
358
+ assertAxis(data, indicators, "indicatorList");
359
+ assertValuesPresent(data, d.values);
360
+ assertMatrixShape(data, d.values, securityCode.length, "securities", indicators.length, "indicators");
361
+ const securityName = resolveSecurityNames(d.securityNameList, securityCode);
141
362
  const headers = indicatorHeaders(indicators, keyBy, CROSS_SECTION_COLUMNS);
142
363
  const list = securityCode.map((code, i) => {
143
- const row = { security: code, name: securityName?.[i] };
364
+ // Only create `name` when there IS one: an always-present `name: undefined`
365
+ // is invisible in JSON but renders as a real empty column in CSV/table.
366
+ const row = securityName ? { security: code, name: securityName[i] } : { security: code };
144
367
  const cells = rowOf(d.values, i);
145
368
  for (let j = 0; j < headers.length; j++) {
146
369
  row[headers[j]] = cells?.[j];
@@ -153,24 +376,65 @@ export function flattenCrossSection(data, keyBy = "name") {
153
376
  // case) or the securities (single-indicator case) — exactly one dimension
154
377
  // varies, per the API contract. `values` stays a 2D [series][date] matrix.
155
378
  //
156
- // `requestedSecurities` decides the axis. Deriving it from the RESPONSE is
157
- // wrong: the server drops securities that have no data at all, so a
158
- // single-indicator × two-security query where one security is uncovered comes
159
- // back with one security and silently relabels the column as the indicator —
160
- // the caller then cannot tell whose series they are holding (probed 2026-08-02:
161
- // `finc_pe_ttm` over 600519.SH + 09992.HK renders a bare 市盈率(TTM) column).
162
- export function flattenTimeSeries(data, keyBy = "name", requestedSecurities) {
163
- if (!data || typeof data !== "object")
164
- return data;
379
+ // Axis rule, in priority order:
380
+ // 1. More than one indicator came back → multi-indicator × single-security.
381
+ // 2. More than one security came back → single-indicator × multi-security.
382
+ // This is what a sector ID produces: the request carries ONE universe entry
383
+ // and the server expands it into N constituents, so the request count says
384
+ // nothing about the axis.
385
+ // 3. Both are 1 → genuinely ambiguous, so fall back to what was REQUESTED:
386
+ // - a universe holding a sector ID always takes the security axis. The
387
+ // sector may expand to one constituent, or the rest may have been dropped
388
+ // for lack of data; either way the caller asked "which securities", and an
389
+ // indicator-named column would erase whose series this is.
390
+ // - otherwise the entry count decides. The server drops securities with no
391
+ // data at all, so a two-security request that comes back with one must
392
+ // still be labelled by security (probed 2026-08-02: `finc_pe_ttm` over
393
+ // 600519.SH + 09992.HK).
394
+ export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
395
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
396
+ 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);
397
+ }
165
398
  const d = data;
166
- const dates = asStringArray(d.dates);
167
- const securityCode = asStringArray(d.securityCodeList);
168
- const indicators = asIndicatorMetaList(d.indicatorList);
169
- if (!Array.isArray(d.values) || !dates || !securityCode || !indicators)
170
- return data;
171
- const securityName = asStringArray(d.securityNameList);
172
- const seriesAreIndicators = (requestedSecurities ?? securityCode.length) <= 1;
173
- assertRowCount(d.values, seriesAreIndicators ? indicators.length : securityCode.length, seriesAreIndicators ? "indicators" : "securities");
399
+ assertMatrixPayload(data, d);
400
+ const dates = asIdentityArray(data, d.dates, "dates");
401
+ const securityCode = asIdentityArray(data, d.securityCodeList, "securityCodeList");
402
+ const indicators = asIndicatorMetaList(data, d.indicatorList);
403
+ assertAxis(data, dates, "dates");
404
+ assertAxis(data, securityCode, "securityCodeList");
405
+ assertAxis(data, indicators, "indicatorList");
406
+ assertValuesPresent(data, d.values);
407
+ const securityName = resolveSecurityNames(d.securityNameList, securityCode);
408
+ // The API permits multi-indicator × single-security OR single-indicator ×
409
+ // multi-security, never both — the server rejects such a REQUEST with 100003.
410
+ // A RESPONSE carrying both axes plural is therefore unattributable: whichever
411
+ // axis becomes the columns, the other one's identity is silently discarded
412
+ // (probed 2026-08-02: 2×2 rendered as 收盘价/成交量 with both securities gone,
413
+ // and droppedFromMatrix sees nothing missing so it would not even flag).
414
+ // Data with only one identity axis is unattributable in the other direction:
415
+ // `securityCodeList: []` alongside a populated matrix leaves every row
416
+ // belonging to no security at all, and the row/column counts still line up so
417
+ // no other guard notices.
418
+ // Without this, dates-with-no-series still passes the later shape check (0 rows
419
+ // for 0 series) and emits one row per date carrying nothing but the date. A
420
+ // response that resolved to nothing empties `dates` too (probed 2026-08-02), so dates
421
+ // alongside a missing axis is always a broken response.
422
+ if (dates.length > 0 && (securityCode.length === 0 || indicators.length === 0)) {
423
+ 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);
424
+ }
425
+ if (securityCode.length > 1 && indicators.length > 1) {
426
+ 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);
427
+ }
428
+ // A universe entry without a `.` is a sector ID — the server expands it, so
429
+ // neither its presence nor the entry count says anything about the axis.
430
+ const requested = requestedUniverse === undefined ? undefined : [...new Set(requestedUniverse)];
431
+ const hasSector = requested?.some((entry) => !entry.includes(".")) ?? false;
432
+ const seriesAreIndicators = indicators.length > 1 ? true
433
+ : securityCode.length > 1 ? false
434
+ : hasSector ? false
435
+ : (requested?.length ?? securityCode.length) <= 1;
436
+ const seriesCount = seriesAreIndicators ? indicators.length : securityCode.length;
437
+ assertMatrixShape(data, d.values, seriesCount, seriesAreIndicators ? "indicators" : "securities", dates.length, "dates");
174
438
  // Map over securityCode rather than securityNameList directly: a response that
175
439
  // omits the names must still yield one header per security (falling back to
176
440
  // the code), not zero columns.
@@ -28,9 +28,11 @@ 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
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.
31
+ // Incomplete results exit with code 3: the table/csv/jsonl renderers only
32
+ // emit the rows, so without a distinct exit code a script or AI consumer
33
+ // cannot tell such an export from a clean one. `partial` means rows are
34
+ // missing — failed pages/shards, a row cap, or an EDE response that dropped
35
+ // whole securities/indicators because it did not recognise those codes.
34
36
  if (meta.partial === true) {
35
37
  process.exitCode = 3;
36
38
  }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.30.1";
2
+ export const CLI_VERSION = "0.32.0";