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.
- package/README.md +17 -6
- package/dist/src/cli.js +188 -15
- package/dist/src/core/args.js +130 -1
- package/dist/src/core/client.js +7 -5
- package/dist/src/core/commandBodies.js +28 -14
- package/dist/src/core/errors.js +12 -6
- package/dist/src/core/indicatorMatrix.js +331 -23
- package/dist/src/core/printer.js +9 -3
- package/dist/src/core/transport.js +6 -5
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +12 -8
- package/gangtise-openapi/references/commands/indicator.md +71 -25
- package/gangtise-openapi/references/commands/quote.md +1 -1
- package/gangtise-openapi/references/examples.md +10 -12
- package/gangtise-openapi/references/response-schema.md +3 -1
- 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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
package/dist/src/core/printer.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
34
|
-
|
|
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
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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".
|
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.31.0";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gangtise-openapi
|
|
3
|
-
version: "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
|
|
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
|
|
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
|
|
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
|
|
176
|
-
- **EDE 指标参数名一律以 `indicator search --format json` 的 `parameterList` 为准**,不要凭记忆或照抄示例:服务端会改参数名(`adjustmentType` → `adjustType`,2026-08-01),**传错名是静默忽略、退回默认值**而不是报错——实测 `adjustmentType=3` 返回的是不复权价,看着正常实则错数据。`--
|
|
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
|
-
| `
|
|
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 =
|
|
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
|
- 下载文件路径冲突 → 询问覆盖
|