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.
@@ -288,6 +288,100 @@ const SCREENER_FIELD = /^F[1-9][0-9]*$/;
288
288
  * `contains 'F2 系列'` operand is not mistaken for a reference. */
289
289
  const SCREENER_FIELD_REF = /\bF[1-9][0-9]*\b/g;
290
290
  const SCREENER_STRING_LITERAL = /'[^']*'|"[^"]*"/g;
291
+ /** Split an expression into `(`, `)`, `&&`, `||` and the atoms between them.
292
+ * String literals are copied verbatim so a `||` or an `F2` inside one is never
293
+ * mistaken for syntax. */
294
+ function tokenizeScreenerExpression(src) {
295
+ const tokens = [];
296
+ let atom = "";
297
+ const flush = () => {
298
+ if (atom.trim())
299
+ tokens.push(atom);
300
+ atom = "";
301
+ };
302
+ for (let i = 0; i < src.length;) {
303
+ const ch = src[i];
304
+ if (ch === "'" || ch === '"') {
305
+ const close = src.indexOf(ch, i + 1);
306
+ const stop = close === -1 ? src.length : close + 1;
307
+ atom += src.slice(i, stop);
308
+ i = stop;
309
+ }
310
+ else if (src.startsWith("&&", i) || src.startsWith("||", i)) {
311
+ flush();
312
+ tokens.push(src.slice(i, i + 2));
313
+ i += 2;
314
+ }
315
+ else if (ch === "(" || ch === ")") {
316
+ flush();
317
+ tokens.push(ch);
318
+ i += 1;
319
+ }
320
+ else {
321
+ atom += ch;
322
+ i += 1;
323
+ }
324
+ }
325
+ flush();
326
+ return tokens;
327
+ }
328
+ /** Whether the expression still has a path that could have been evaluated, given
329
+ * the variables the response actually returned a column for.
330
+ *
331
+ * The boolean structure decides, not the mere presence of a `||`:
332
+ * - a term is evaluable when every variable it names has a column;
333
+ * - `A && B` needs BOTH sides — a missing mandatory conjunct is an unprovable
334
+ * claim even if the other side is a disjunction;
335
+ * - `A || B` needs only one — a row can legitimately match through one operand
336
+ * while the other is not evaluable at all (probed 2026-08-03: `F1 > 0 || F2
337
+ * > 0` over 09992.HK, where `finc_pe_ttm` has no HK coverage, matches on F2).
338
+ *
339
+ * So `F1 && (F2 || F3)` missing F1, and `F1 || F2` missing both, are both
340
+ * unevaluable and must fail — checking only for a `||` anywhere would let them
341
+ * through. */
342
+ export function screenerExpressionIsEvaluable(expression, present) {
343
+ const tokens = tokenizeScreenerExpression(expression ?? "");
344
+ let pos = 0;
345
+ const unit = () => {
346
+ if (tokens[pos] === "(") {
347
+ pos += 1;
348
+ const value = or();
349
+ if (tokens[pos] === ")")
350
+ pos += 1;
351
+ return value;
352
+ }
353
+ const atom = tokens[pos++] ?? "";
354
+ // A term naming no variable (a literal comparison) is always evaluable.
355
+ return (atom.replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? []).every((ref) => present.has(ref));
356
+ };
357
+ const and = () => {
358
+ let value = unit();
359
+ // Evaluate both sides before combining: short-circuiting would leave the
360
+ // parser mid-expression.
361
+ while (tokens[pos] === "&&") {
362
+ pos += 1;
363
+ const right = unit();
364
+ value = value && right;
365
+ }
366
+ return value;
367
+ };
368
+ const or = () => {
369
+ let value = and();
370
+ while (tokens[pos] === "||") {
371
+ pos += 1;
372
+ const right = and();
373
+ value = value || right;
374
+ }
375
+ return value;
376
+ };
377
+ return or();
378
+ }
379
+ /** Variables the expression actually filters on, string literals stripped. These
380
+ * are the bindings whose VALUES the result depends on — a column missing for one
381
+ * of them means the filter cannot be shown to have been applied. */
382
+ export function screenerExpressionFields(expression) {
383
+ return (expression ?? "").replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? [];
384
+ }
291
385
  // Parse the screener's `--indicator "F1:code"` bindings and merge each one's
292
386
  // `--indicator-param "F1:key=value"` specs. Params key off the variable, not the
293
387
  // code, because the same indicator may legitimately appear under two variables
@@ -320,26 +414,10 @@ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
320
414
  // The server does reject this (100003), but only after a round trip — and the
321
415
  // symmetric mistake (binding a variable the expression never uses) it accepts
322
416
  // silently while still billing the extra column.
323
- for (const ref of (expression ?? "").replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? []) {
417
+ for (const ref of screenerExpressionFields(expression)) {
324
418
  if (!indicators.has(ref)) {
325
419
  throw new ValidationError(`--expression references "${ref}", which no --indicator binds`);
326
420
  }
327
421
  }
328
422
  return [...indicators.values()];
329
423
  }
330
- /** Indicator codes bound to more than one screener variable, in first-seen
331
- * order. The API is specified to allow this (one code under two variables with
332
- * different parameters), but the server currently answers such a request with an
333
- * empty result set — probed 2026-08-02. Callers warn rather than reject: the
334
- * capability is intended and a server-side fix is in flight, so a hard block
335
- * here would have to be reverted. */
336
- export function duplicateScreenerCodes(indicators) {
337
- const seen = new Set();
338
- const duplicated = new Set();
339
- for (const { indicatorCode } of indicators) {
340
- if (seen.has(indicatorCode))
341
- duplicated.add(indicatorCode);
342
- seen.add(indicatorCode);
343
- }
344
- return [...duplicated];
345
- }
@@ -427,8 +427,9 @@ export class GangtiseClient {
427
427
  },
428
428
  }).catch((error) => {
429
429
  // Until 2026-08-01 EDE answered a no-data query with 999999, so this hint
430
- // led with "多为查询无数据". No-data now comes back as an empty array, which
431
- // leaves 999999 meaning an actual server fault — but the parameter checklist
430
+ // led with "多为查询无数据". No-data now comes back as a null CELL with its
431
+ // row and column intact (re-probed 2026-08-08), which leaves 999999 meaning
432
+ // an actual server fault — but the parameter checklist
432
433
  // is still the right first move, because a wrong param name or date axis
433
434
  // silently yields an EMPTY TABLE rather than this error. Only the data
434
435
  // endpoints take a date/security/params; indicator.search shares the
@@ -436,7 +437,7 @@ export class GangtiseClient {
436
437
  // instead of nonsensical date/scope/param guidance.
437
438
  const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series' || endpoint.key === 'indicator.screener';
438
439
  if (isIndicatorFetch && error instanceof ApiError && error.code === '999999') {
439
- throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名是静默失效)、日期匹配指标周期(财务/MRQ 用报告期末如 2025-12-31、日频估值用交易日)、标的在 scopeList 覆盖内、required 参数已补。注意「查询无数据」现在返回空表而不是此码。');
440
+ throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名是静默失效)、日期匹配指标周期(财务报表类用报告期末如 2025-12-31,PE/PB 等日频估值用交易日)、标的在 scopeList 覆盖内、required 参数已补。注意此码不表示无数据——有效 code 无数据现在返回保留行列的 null 单元格,空表则多半是整个轴的 code 都没被识别。');
440
441
  }
441
442
  throw error;
442
443
  });
@@ -36,12 +36,17 @@ export function buildStockPoolStocksBody(options) {
36
36
  }
37
37
  /** Parameter keys that REPLACE an indicator's `tradeDate`. The 2026-08-01 EDE
38
38
  * revision dropped the root-level `date`, so `--date` now fans out into each
39
- * indicator's `tradeDate`. Report-period indicators read `reportDate` instead
40
- * and answer a `tradeDate` with an EMPTY result rather than an error (probed
41
- * 2026-08-01 on `is_op_rev_mom`), so a caller-supplied `reportDate` suppresses
42
- * the injection for that indicator. Injecting into an indicator that takes no
43
- * date at all is harmless — `pty_op_scope` still answers normally with a stray
44
- * `tradeDate` attached.
39
+ * indicator's `tradeDate`. A caller-supplied `reportDate` suppresses that
40
+ * injection for its indicator, so the two date fields are never sent together.
41
+ *
42
+ * This used to be load-bearing in a stronger sense: a report-period indicator
43
+ * answered a `tradeDate` with an EMPTY result rather than an error (probed
44
+ * 2026-08-01 on `is_op_rev_mom`). Re-probed 2026-08-08 — the server now resolves
45
+ * a `tradeDate` to the enclosing report period, and `is_op_rev_mom` returns the
46
+ * same 33.4903 either way. Kept regardless: sending the caller's own date field
47
+ * rather than a second one alongside it is the honest request, and it survives a
48
+ * rollback. Injecting into an indicator that takes no date at all is harmless —
49
+ * `pty_op_scope` still answers normally with a stray `tradeDate` attached.
45
50
  *
46
51
  * `sDate` is deliberately NOT here. It is an interval START, not a substitute:
47
52
  * `qte_vol_intvl` declares `tradeDate` required (the interval END) and `sDate`
@@ -92,11 +97,15 @@ export function buildIndicatorScreenerBody(options) {
92
97
  universe: maybeArray(options.security),
93
98
  expression: options.expression,
94
99
  // Every indicator gets a date, including ones whose parameterList is empty.
95
- // That is not just convenience: the screener drops any indicator sent with
96
- // `parameters: []` — probed 2026-08-02, `pty_op_scope` + `F1 contains '酒'`
97
- // matches nothing with an empty list and correctly returns 五粮液/贵州茅台
98
- // with a (for that indicator meaningless) tradeDate attached. The official
99
- // doc's own `F3 contains '酒'` example cannot work as written.
100
+ // Harmless there (a parameterless indicator answers normally with a stray
101
+ // tradeDate), and it keeps one rule for the whole list rather than a
102
+ // per-indicator exception.
103
+ //
104
+ // It also used to be load-bearing: through 2026-08-02 the screener DROPPED
105
+ // any indicator sent with `parameters: []`, so the official doc's own
106
+ // `F3 contains '酒'` example could not work as written. Re-probed 2026-08-03:
107
+ // fixed server-side. Kept anyway — unconditional is simpler than conditional,
108
+ // and it survives a rollback.
100
109
  indicatorList: indicators.map((indicator) => (indicator.parameters.some((param) => DATE_PARAM_KEYS.has(param.paramKey))
101
110
  ? indicator
102
111
  : { ...indicator, parameters: [...indicator.parameters, { paramKey: "tradeDate", paramValue: options.date }] })),
@@ -49,6 +49,24 @@ const ENDPOINT_DEFS = {
49
49
  // 50/篇 — same price tier as the AI Agent calls; billing probed non-idempotent.
50
50
  retry: "no-replay",
51
51
  },
52
+ "insight.pamirs-summary.list": {
53
+ method: "POST",
54
+ path: "/application/open-insight/pamirs-summary/getList",
55
+ kind: "json",
56
+ description: "List Pamirs expert summaries (requires the expert-summary database)",
57
+ pagination: { enabled: true, maxPageSize: 50 },
58
+ },
59
+ "insight.pamirs-summary.download": {
60
+ method: "GET",
61
+ path: "/application/open-insight/pamirs-summary/download/file",
62
+ kind: "download",
63
+ description: "Download a Pamirs expert summary file",
64
+ // The 2026-08-07 spec states an entitlement (the expert-summary database) but
65
+ // no per-call price. Treated as non-idempotent anyway, like its
66
+ // `insight.summary.download` sibling: if it does meter, a 5xx replay
67
+ // double-bills, and the only cost of being wrong is losing one retry.
68
+ retry: "no-replay",
69
+ },
52
70
  "insight.roadshow.list": {
53
71
  method: "POST",
54
72
  path: "/application/open-insight/schedule/roadshow/getList",
@@ -72,7 +72,9 @@ const ERROR_HINTS = {
72
72
  // --report-date 的用户越导越错。
73
73
  "110001": "看参数名:`--*-date` 用 YYYY-MM-DD,`--*-time` 用 \"YYYY-MM-DD HH:mm:ss\"(`ai knowledge-batch` 的 --start-time/--end-time 收时间戳或 datetime,CLI 统一转 13 位毫秒)。",
74
74
  "110002": "起始晚于结束——检查 --start-date/--end-date 或 --start-time/--end-time 的先后。",
75
- "110003": "缩短查询窗口后重试。",
75
+ // 超出的是账号数据权限的时间下界,不是单次窗口太宽——`--fiscal-year 2015` 这种
76
+ // 整个区间都在界外的请求,怎么缩窗口都还是这个码(实测 2026-08-08)。
77
+ "110003": "查询时间超出本账号的数据权限范围——把日期移进范围内(整个区间都早于下界时缩短窗口无用),或联系客户经理开通更长历史。",
76
78
  "120001": "用 `gangtise reference securities-search` 确认代码与后缀(如 600519.SH / 00700.HK)。",
77
79
  "130001": "未找到数据——先核对查询条件;EDE 指标端点此码也可能是未开通该指标权限,仍失败联系客户经理。",
78
80
  "130002": "确认下载 ID 有效且本账号可见;下载类还需检查 --file-type 取值是否合法(非法 file-type 也归此码)。",
@@ -91,6 +93,9 @@ const ERROR_HINTS = {
91
93
  "210001": "换一篇,或改用 list 取正文摘要。",
92
94
  "220001": "改用 list 取正文摘要。",
93
95
  "230001": "只有自己上传的文件可下载。",
96
+ // 2026-08-07 新增码。私域模块,`vault wechat-*` 就在这个模块下且明确要求
97
+ // 「已绑定并激活群消息助理」,所以本 CLI 够得着,不能当作不可达而不给提示。
98
+ "230002": "微信账号未绑定——群消息类接口要求先在 Gangtise 端绑定并激活群消息助理、且助理已入群。",
94
99
  "240001": "换更早的 --period(如 2025q3 → 2025interim)。",
95
100
  "240002": "改述后重新提交。",
96
101
  "240003": "对照命令 --help 检查取值。",
@@ -130,8 +135,10 @@ export class ApiError extends CliError {
130
135
  /** Server-specified Retry-After (ms), set on 429 responses so the transport
131
136
  * backoff can honor it instead of the default exponential schedule. */
132
137
  retryAfterMs,
133
- /** Context-specific hint that beats the generic per-code table — e.g. EDE's
134
- * 999999 means "no data", not the table's "系统错误,请稍后重试". */
138
+ /** Context-specific hint that beats the generic per-code table — e.g. the
139
+ * EDE fetch endpoints replace 999999's generic "系统错误,请稍后重试" with a
140
+ * parameter checklist, because a wrong param name or date axis there yields
141
+ * an empty TABLE rather than this code. */
135
142
  hintOverride) {
136
143
  super(message);
137
144
  this.code = code;