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.
- package/README.md +17 -8
- package/dist/src/cli.js +216 -56
- package/dist/src/core/args.js +95 -17
- package/dist/src/core/client.js +4 -3
- package/dist/src/core/commandBodies.js +20 -11
- package/dist/src/core/endpoints.js +18 -0
- package/dist/src/core/errors.js +10 -3
- package/dist/src/core/indicatorMatrix.js +312 -48
- package/dist/src/core/printer.js +5 -3
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +41 -16
- package/gangtise-openapi/references/commands/indicator.md +121 -38
- package/gangtise-openapi/references/commands/insight.md +51 -1
- package/gangtise-openapi/references/commands/quote.md +1 -1
- package/gangtise-openapi/references/commands/reference-and-lookup.md +4 -4
- package/gangtise-openapi/references/commands/vault.md +2 -2
- package/gangtise-openapi/references/examples.md +36 -12
- package/gangtise-openapi/references/lookup-ids.md +2 -2
- package/gangtise-openapi/references/response-schema.md +6 -2
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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((
|
|
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
|
-
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// the
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
package/dist/src/core/printer.js
CHANGED
|
@@ -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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
}
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated — DO NOT EDIT
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.32.0";
|