gangtise-openapi-cli 0.34.0 → 0.35.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,11 +1,7 @@
1
1
  import { isVerbose, PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
2
2
  const DAY_MS = 86_400_000;
3
- /** API-side row cap (per docs). Used to lift the default 6000-row cap on whole-market
4
- * queries so a 2-day A-share shard (~11K rows) isn't silently truncated. Single-security
5
- * queries are untouched. */
6
3
  const ALL_MARKET_LIMIT = 10_000;
7
4
  function parseDate(value) {
8
- // Accept yyyy-MM-dd; reject anything else so we can fall back to a single request.
9
5
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
10
6
  return null;
11
7
  const d = new Date(`${value}T00:00:00Z`);
@@ -22,7 +18,6 @@ export function isFullMarket(body, fullMarketValue) {
22
18
  return false;
23
19
  return list[0] === fullMarketValue;
24
20
  }
25
- /** Sat/Sun in UTC — shard dates are formatted from UTC midnight (see parseDate). */
26
21
  function isWeekendUtc(d) {
27
22
  const day = d.getUTCDay();
28
23
  return day === 0 || day === 6;
@@ -33,16 +28,6 @@ function buildShards(start, end, shardDays) {
33
28
  const endTime = end.getTime();
34
29
  while (cursor <= endTime) {
35
30
  const shardEnd = Math.min(cursor + (shardDays - 1) * DAY_MS, endTime);
36
- // Per-day sharding (shardDays===1): a lone weekend day always returns empty (A/HK/US
37
- // markets closed) — skip it to save ~28% of requests and daily quota. This covers
38
- // every 1-day-sharded full-market query: fund-flow AND day-kline (aShares/usStocks)
39
- // / day-kline-us.
40
- //
41
- // Multi-day shards (day-kline hkStocks=2, index-day-kline=15) are NOT filtered. Note
42
- // this is a deliberate simplification, not a claim that they always contain a weekday:
43
- // a 2-day shard starting on a Saturday is Sat+Sun and returns nothing. That costs one
44
- // wasted request at a range boundary and never drops a trading day, whereas filtering
45
- // multi-day windows correctly would mean walking each window's days.
46
31
  if (!(shardDays === 1 && isWeekendUtc(new Date(cursor)))) {
47
32
  shards.push({
48
33
  startDate: formatDate(new Date(cursor)),
@@ -53,36 +38,17 @@ function buildShards(start, end, shardDays) {
53
38
  }
54
39
  return shards;
55
40
  }
56
- /**
57
- * For full-market (`--security all`) K-line queries that span more than `shardDays`,
58
- * split the date range and run shards in parallel. Each shard is sized so the
59
- * combined row count stays under the 10K-row API limit. For small ranges or
60
- * single-security queries this is a no-op.
61
- */
62
41
  export async function callKlineWithSharding(client, endpointKey, body, config) {
63
42
  const fullMarketValue = config.fullMarketValue ?? "all";
64
43
  if (!isFullMarket(body, fullMarketValue)) {
65
44
  return client.call(endpointKey, body);
66
45
  }
67
- // `--security all` returns thousands of rows per day; lift the default 6000-row
68
- // cap to the API max so single-shard requests aren't silently truncated. This
69
- // must apply even when a date is missing (no sharding possible then, but the
70
- // single request still needs the lifted cap).
71
46
  const allMarketBody = { ...body, limit: body.limit ?? ALL_MARKET_LIMIT };
72
47
  const perShardLimit = allMarketBody.limit ?? ALL_MARKET_LIMIT;
73
- // A shard maxes out for two reasons with different fixes: a user-set low --limit is
74
- // raisable; hitting the API cap itself is not (only a smaller internal shardDays window
75
- // would help). Word the truncation warning accordingly.
76
48
  const truncationHint = perShardLimit < ALL_MARKET_LIMIT
77
49
  ? "raise or omit --limit to fetch the full market"
78
50
  : `a single ${config.shardDays}-day window exceeds the ${ALL_MARKET_LIMIT}-row API cap`;
79
- // A full-market response whose row count reaches the per-request limit was itself
80
- // capped (a low user --limit, or a single day exceeding the API row cap) — its slice
81
- // is incomplete, so the result must be flagged partial rather than shown as complete.
82
51
  const isTruncated = (rec) => Array.isArray(rec.list) && rec.list.length >= perShardLimit;
83
- // A single full-market request (missing/unparseable dates, or a range that fits one
84
- // shard) skips the merge loop below, so it needs the same truncation check inline — or
85
- // a low --limit / oversized day slips through as a silent exit-0 success.
86
52
  const callSingle = async () => {
87
53
  const single = await client.call(endpointKey, allMarketBody);
88
54
  if (single && typeof single === "object" && !Array.isArray(single) && isTruncated(single)) {
@@ -105,37 +71,21 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
105
71
  return callSingle();
106
72
  }
107
73
  const shards = buildShards(start, end, config.shardDays);
108
- // A range that's entirely weekends (per-day sharding) filters down to nothing — no
109
- // trading days to fetch. Return empty rather than falling into the all-shards-failed
110
- // check below, which would misread 0 shards as a total outage and throw.
111
74
  if (shards.length === 0)
112
75
  return { list: [] };
113
- // isVerbose() (not a direct env read) so the global --verbose flag reaches
114
- // shard logging too — cli.ts enables it via setVerbose in a preAction hook.
115
76
  if (isVerbose()) {
116
77
  process.stderr.write(`[gangtise] sharding ${endpointKey} into ${shards.length} requests (${config.shardDays} day(s) each)\n`);
117
78
  }
118
- // Per-shard fault tolerance: a failing shard is recorded and skipped (returns a
119
- // null sentinel) instead of rejecting, so the surviving shards still complete.
120
- // runWithConcurrency uses Promise.all under the hood, which would otherwise abort
121
- // every shard on the first rejection.
122
79
  const failedShards = [];
123
80
  let firstError = null;
124
81
  let aborted = false;
125
82
  const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
126
- // A prior shard hit a hard error (rate limit, no-perm, retries exhausted). Stop
127
- // dispatching the rest rather than burning quota into the same failure; record them
128
- // as failed so the merged result is flagged partial. Mirrors requestPaginated.
129
83
  if (aborted) {
130
84
  failedShards.push(shard);
131
85
  return null;
132
86
  }
133
87
  try {
134
88
  const res = await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
135
- // A shard that resolves but carries no `list` array is shape-broken (an error
136
- // object, a truncated envelope) — its rows are missing. Treat it exactly like a
137
- // thrown shard so the result is marked partial, not silently short. Unlike a hard
138
- // error this doesn't abort the fan-out: one malformed shard isn't systemic.
139
89
  if (!(res && typeof res === "object" && Array.isArray(res.list))) {
140
90
  failedShards.push(shard);
141
91
  return null;
@@ -153,8 +103,6 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
153
103
  let fieldList;
154
104
  let header = null;
155
105
  const merged = [];
156
- // Record WHICH windows maxed out, not just how many: a script/agent consumer
157
- // needs the concrete date ranges to re-pull narrower windows (mirrors failedShards).
158
106
  const truncatedShards = [];
159
107
  for (let i = 0; i < results.length; i++) {
160
108
  const r = results[i];
@@ -167,21 +115,15 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
167
115
  fieldList = rec.fieldList;
168
116
  if (isTruncated(rec))
169
117
  truncatedShards.push(shards[i]);
170
- // Append one-by-one rather than push(...list): a future higher row cap could
171
- // make a single shard's list large enough to overflow the stack via spread.
172
118
  if (Array.isArray(rec.list))
173
119
  for (const item of rec.list)
174
120
  merged.push(item);
175
121
  }
176
- // Every shard failed → surface the error loudly (non-zero exit) rather than
177
- // masking a total outage as an empty success.
178
122
  if (failedShards.length === shards.length) {
179
123
  throw firstError ?? new Error(`All ${shards.length} kline shards failed`);
180
124
  }
181
125
  if (!header)
182
126
  return { list: [] };
183
- // `total` on a shard is that shard's own row count; overwrite it with the merged count
184
- // so the JSON `total` and the `Total:` stderr line reflect the whole combined result.
185
127
  const out = { ...header, total: merged.length, list: merged };
186
128
  if (fieldList)
187
129
  out.fieldList = fieldList;
@@ -4,63 +4,65 @@ import path from "node:path";
4
4
  export const DEFAULT_TITLE_CACHE_PATH = path.join(os.homedir(), ".config", "gangtise", "title-cache.json");
5
5
  export const TITLE_LOOKUP_SIZE = 200;
6
6
  const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
- /**
8
- * Hard cap on titles kept per endpoint. The cache only needs the recent working
9
- * set so downloads can resolve friendly filenames; without a cap, `writeTitleCache`
10
- * merges forever and the endpoint's `ts` refreshes on every write, so the TTL
11
- * never expires it — the file grew to tens of MB in practice, and `resolveTitle`
12
- * re-parses the whole thing on every download. Bounds the file to roughly
13
- * (#cached endpoints × this × avg entry size).
14
- */
15
7
  export const MAX_TITLES_PER_ENDPOINT = 5_000;
16
- // Per-process in-memory snapshot of the cache. We read the file at most once,
17
- // merge subsequent writes in memory, and flush atomically. This avoids the
18
- // "read whole file → modify → write whole file" pattern firing on every list
19
- // command (which got expensive once dozens of endpoints accumulated).
20
8
  let memoryCache = null;
21
9
  let memoryCachePath = null;
22
10
  let pendingWrite = null;
11
+ let pendingLoad = null;
12
+ let pendingLoadPath = null;
23
13
  let dirty = false;
24
14
  async function loadInto(filePath) {
25
15
  if (memoryCache && memoryCachePath === filePath)
26
16
  return memoryCache;
17
+ if (pendingLoad && pendingLoadPath === filePath)
18
+ return pendingLoad;
19
+ const load = (async () => {
20
+ try {
21
+ const content = await fs.readFile(filePath, "utf8");
22
+ const parsed = JSON.parse(content);
23
+ memoryCache = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
24
+ }
25
+ catch {
26
+ memoryCache = {};
27
+ }
28
+ memoryCachePath = filePath;
29
+ return memoryCache;
30
+ })();
31
+ pendingLoad = load;
32
+ pendingLoadPath = filePath;
27
33
  try {
28
- const content = await fs.readFile(filePath, "utf8");
29
- const parsed = JSON.parse(content);
30
- memoryCache = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
34
+ return await load;
31
35
  }
32
- catch {
33
- memoryCache = {};
36
+ finally {
37
+ if (pendingLoad === load) {
38
+ pendingLoad = null;
39
+ pendingLoadPath = null;
40
+ }
34
41
  }
35
- memoryCachePath = filePath;
36
- return memoryCache;
37
42
  }
38
43
  export async function readTitleCache(filePath = DEFAULT_TITLE_CACHE_PATH) {
39
44
  return loadInto(filePath);
40
45
  }
41
46
  async function flush(filePath) {
42
- if (!dirty || !memoryCache)
43
- return;
44
- dirty = false;
45
- const snapshot = JSON.stringify(memoryCache);
46
- await fs.mkdir(path.dirname(filePath), { recursive: true });
47
- // Atomic-ish: write to temp file then rename (rename is atomic within a fs).
48
- const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
49
- await fs.writeFile(tmp, snapshot, { encoding: "utf8", mode: 0o600 });
50
- try {
51
- await fs.rename(tmp, filePath);
52
- }
53
- catch (error) {
54
- await fs.unlink(tmp).catch(() => { });
55
- throw error;
47
+ for (;;) {
48
+ if (!dirty || !memoryCache) {
49
+ pendingWrite = null;
50
+ return;
51
+ }
52
+ dirty = false;
53
+ const snapshot = JSON.stringify(memoryCache);
54
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
55
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
56
+ await fs.writeFile(tmp, snapshot, { encoding: "utf8", mode: 0o600 });
57
+ try {
58
+ await fs.rename(tmp, filePath);
59
+ }
60
+ catch (error) {
61
+ await fs.unlink(tmp).catch(() => { });
62
+ throw error;
63
+ }
56
64
  }
57
65
  }
58
- /**
59
- * Trim `merged` down to `cap` entries. The freshly-written ids (`freshKeys`) are
60
- * kept first since those are what the user just listed and is most likely to
61
- * download; remaining capacity is filled from the rest. Returns `merged` as-is
62
- * when already within the cap.
63
- */
64
66
  function capTitles(merged, freshKeys, cap) {
65
67
  if (Object.keys(merged).length <= cap)
66
68
  return merged;
@@ -75,8 +77,6 @@ function capTitles(merged, freshKeys, cap) {
75
77
  }
76
78
  }
77
79
  if (n < cap) {
78
- // Newest-inserted keys sit at the END of the merged object — fill in reverse so
79
- // eviction drops the oldest entries, not the batch the user just listed yesterday.
80
80
  const rest = Object.keys(merged);
81
81
  for (let i = rest.length - 1; i >= 0 && n < cap; i--) {
82
82
  const k = rest[i];
@@ -88,12 +88,6 @@ function capTitles(merged, freshKeys, cap) {
88
88
  }
89
89
  return out;
90
90
  }
91
- /**
92
- * Keep the cache bounded on every write: drop endpoint entries past their TTL
93
- * (lookups already ignore them) and cap each endpoint's titles. The just-written
94
- * endpoint keeps its newest ids first; others over the cap are trimmed too, so a
95
- * pre-existing oversized file shrinks on the next write.
96
- */
97
91
  function pruneCache(data, freshEndpoint, freshKeys) {
98
92
  const now = Date.now();
99
93
  for (const key of Object.keys(data)) {
@@ -117,15 +111,15 @@ export async function writeTitleCache(endpoint, titles, filePath = DEFAULT_TITLE
117
111
  data[endpoint] = { titles: { ...existing, ...titles }, ts: Date.now() };
118
112
  pruneCache(data, endpoint, Object.keys(titles));
119
113
  dirty = true;
120
- // Coalesce concurrent writes: the in-flight flush picks up everything dirty.
121
114
  if (pendingWrite)
122
115
  return pendingWrite;
123
116
  pendingWrite = (async () => {
124
117
  try {
125
118
  await flush(filePath);
126
119
  }
127
- finally {
120
+ catch (error) {
128
121
  pendingWrite = null;
122
+ throw error;
129
123
  }
130
124
  })();
131
125
  return pendingWrite;
@@ -151,10 +145,11 @@ export function extractTitles(items, cache) {
151
145
  }
152
146
  return titles;
153
147
  }
154
- /** Test-only hook to reset the in-memory snapshot between cases. */
155
148
  export function __resetTitleCacheForTests() {
156
149
  memoryCache = null;
157
150
  memoryCachePath = null;
158
151
  pendingWrite = null;
152
+ pendingLoad = null;
153
+ pendingLoadPath = null;
159
154
  dirty = false;
160
155
  }
@@ -1,25 +1,11 @@
1
1
  import { gunzipSync } from "node:zlib";
2
2
  import { Agent } from "undici";
3
3
  import { ApiError } from "./errors.js";
4
- /** Decode an HTTP response body honoring Content-Encoding. undici does not
5
- * auto-decompress, so requestJson advertises `accept-encoding: gzip` and gunzips
6
- * here; an unencoded body is read as-is. */
7
4
  export function decodeResponseBody(buf, contentEncoding) {
8
5
  const enc = (Array.isArray(contentEncoding) ? contentEncoding[0] : contentEncoding)?.toLowerCase().trim();
9
6
  const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
10
7
  return enc === "gzip" ? gunzipSync(b).toString("utf8") : b.toString("utf8");
11
8
  }
12
- /**
13
- * Re-quote the *unquoted numeric* values of the named JSON fields so ids past
14
- * 2^53 survive JSON.parse: `{"taskId":1782345678901234567}` would otherwise come
15
- * back as 1782345678901234700 and address a task that does not exist.
16
- *
17
- * Probed 2026-07-25: file-parse's submit returns `taskId` as a STRING (matching
18
- * the spec), so this is a forward guard, not a live fix — but the failure mode it
19
- * covers is silent and expensive (the parse job is already billed, and a rounded
20
- * id can never fetch its result). Values already in quotes are left alone: the
21
- * pattern only matches a bare number right after the key.
22
- */
23
9
  export function quoteBigIntFields(text, fields) {
24
10
  if (!fields?.length)
25
11
  return text;
@@ -29,11 +15,7 @@ export function quoteBigIntFields(text, fields) {
29
15
  }
30
16
  return out;
31
17
  }
32
- /** A Retry-After delay we'll honor even if it exceeds maxDelay — but never past this
33
- * ceiling, so a hostile/misconfigured header can't hang the CLI for minutes. */
34
18
  const RETRY_AFTER_CEILING_MS = 60_000;
35
- /** Parse a Retry-After header (delta-seconds or an HTTP-date) into a delay in ms.
36
- * Returns undefined when absent or unparseable. `nowMs` is injected for testing. */
37
19
  export function parseRetryAfterMs(value, nowMs) {
38
20
  const raw = (Array.isArray(value) ? value[0] : value)?.trim();
39
21
  if (!raw)
@@ -43,8 +25,6 @@ export function parseRetryAfterMs(value, nowMs) {
43
25
  const dateMs = Date.parse(raw);
44
26
  return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - nowMs);
45
27
  }
46
- /** A retryable error may carry a server-specified Retry-After (attached by the
47
- * client on a 429); prefer it over the computed backoff. */
48
28
  function retryAfterFromError(error) {
49
29
  if (error && typeof error === "object") {
50
30
  const v = error.retryAfterMs;
@@ -82,37 +62,17 @@ export async function runWithConcurrency(items, concurrency, fn) {
82
62
  await Promise.all(Array.from({ length: limit }, () => worker()));
83
63
  return results;
84
64
  }
85
- /** Parse GANGTISE_PAGE_CONCURRENCY defensively. runWithConcurrency clamps to
86
- * ≥1 worker, so a negative/zero/NaN value silently degrades to SERIAL fetching
87
- * (slow, confusing); an absurd value fans out up to items.length workers at
88
- * once and can 429-storm the server. Fall back to the default and cap at 32. */
89
65
  export function resolvePageConcurrency(raw, fallback = 5, max = 32) {
90
66
  const parsed = Math.floor(Number(raw));
91
67
  if (!Number.isFinite(parsed) || parsed < 1)
92
68
  return fallback;
93
69
  return Math.min(parsed, max);
94
70
  }
95
- /** Fan-out width for pagination and kline shards — one env knob tunes both. */
96
71
  export const PAGE_CONCURRENCY = resolvePageConcurrency(process.env.GANGTISE_PAGE_CONCURRENCY);
97
72
  const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
98
73
  const RETRYABLE_NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
99
74
  const RETRYABLE_API_CODES = new Set(["999999"]);
100
- // Never replayed on any HTTP status:
101
- // - 999011 CREDENTIAL_INVALID: bad AK/SK, only from auth.login (useAuth=false, so it
102
- // never consults AUTH_RETRY_CODES, and it declares no retry policy) — a 5xx would
103
- // otherwise be retried twice by the status rule.
104
- // - 140002 PROCESSING_FAILED: the async *-check endpoints (get-content) declare no
105
- // retry policy, so a 140002@500 would be retried 2× by the default policy BEFORE
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 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.
113
75
  const TERMINAL_API_CODES = new Set(["999011", "140002"]);
114
- // Connect-phase / DNS failures: the request provably never reached the server, so a
115
- // replay cannot double-execute (or double-bill) anything even under "no-replay".
116
76
  const NO_REPLAY_NETWORK_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
117
77
  function isRetryableError(error, policy) {
118
78
  if (error && typeof error === "object" && error.__retryable === true) {
@@ -146,9 +106,6 @@ function isRetryableError(error, policy) {
146
106
  export function markRetryable(error) {
147
107
  return Object.assign(error, { __retryable: true });
148
108
  }
149
- /** Errors worth waiting out (anything the default policy would retry): transient
150
- * 5xx / network / timeout / 429 / 999999. Used by async polling to survive a
151
- * blip without abandoning a multi-minute wait. */
152
109
  export function isTransientError(error) {
153
110
  return isRetryableError(error, "default");
154
111
  }
@@ -164,8 +121,6 @@ export async function withRetry(fn, options = {}) {
164
121
  catch (error) {
165
122
  if (attempt >= retries || !isRetryableError(error, options.policy ?? "default"))
166
123
  throw error;
167
- // A server-sent Retry-After (429) wins over exponential backoff, but is capped
168
- // so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
169
124
  const retryAfter = retryAfterFromError(error);
170
125
  const delay = retryAfter !== undefined
171
126
  ? Math.min(retryAfter, RETRY_AFTER_CEILING_MS)
@@ -1,2 +1 @@
1
- // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.34.0";
1
+ export const CLI_VERSION = "0.35.0";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.34.0"
3
+ version: "0.35.0"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
@@ -265,7 +265,7 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
265
265
 
266
266
  日期参数**按参数名判断、不按命令组**(命令组会误导——AI 里既有 `--start-time` 又有 `--date`/`--report-date`;Insight 里 `performance-calendar` 是唯一用 `--start-date`/`--end-date` 的 list):名字带 `-date` 的(`--start-date`/`--end-date`/`--date`/`--report-date`)一律 `YYYY-MM-DD`,覆盖 Quote/Fundamental、`insight performance-calendar`、AI 的 `theme-tracking`(`--date`)/`hot-topic`/`management-discuss-*`(`--report-date`)、Alternative `edb-data`、Indicator `cross-section`(`--date`)/`time-series`;名字带 `-time` 的(`--start-time`/`--end-time`)用 `YYYY-MM-DD[ HH:mm[:ss]]`(秒可省、空格或 `T` 分隔)或 10/13 位时间戳,覆盖 Insight/Vault 各 list、`quote minute-kline`、`ai security-clue`、`ai knowledge-batch`。其中 **A 股公告(`insight announcement list`)与 `knowledge-batch` 会把输入转成 13 位毫秒**(10 位秒自动 ×1000),其余 `-time` 命令(含 `announcement-hk`/`announcement-us`)原样透传字符串;CLI 输入统一接受 10/13 位纯数字或 `YYYY-MM-DD[ HH:mm[:ss]]`(同上:秒可省、空格或 `T` 分隔)。
267
267
 
268
- 支持排序切换的命令:opinion / summary / pamirs-summary / research / foreign-report / announcement / announcement-hk / announcement-us / foreign-opinion / independent-opinion / official-account。**要最新的加 `--rank-type 2`(严格按 `publishTime` 倒序);要最相关的用默认 `--rank-type 1` + `--keyword` + `--search-type 2`(综合排序按相关度挑条目)**——两者从同一结果集里取的是**不同的子集**,不是同一批内容换个排法。⚠️ 综合排序**挑完之后仍按时间倒序排列**,所以别用「结果是不是时间倒序」判断它有没有生效,要比条目 ID。没有 `--keyword` 时两者无差异,属正常。详见 `insight.md` 开头。其他 list 命令按 API 默认排序。
268
+ 支持排序切换的命令:opinion / summary / pamirs-summary / research / foreign-report / announcement / announcement-hk / announcement-us / foreign-opinion / independent-opinion / official-account。**要最新的加 `--rank-type 2`(严格按 `publishTime` 倒序);要最相关的用默认 `--rank-type 1` + `--keyword`(综合排序按相关度挑条目)**——两者从同一结果集里取的是**不同的子集**,不是同一批内容换个排法。🔴 **`--search-type` 不改变 `--rank-type 1` 取回哪些条目**(实测 19 组 0 反例,前 50 逐位相同,其中一组 `total` 从 1522 涨到 45257 仍逐位不变),**要相关度不必加 `--search-type 2`**;它扩大的是命中总数和 `--rank-type 2` 的候选池。🔴 **两档差别有多大取决于关键词,别拿一个词判断参数有没有用**:有些关键词下两档返回同一批,有些交集接近 0,判据见 `insight.md` 开头。⚠️ 综合排序**挑完之后仍按时间倒序排列**,所以别用「结果是不是时间倒序」判断它有没有生效,要比条目 ID。没有 `--keyword` 时两者无差异,属正常。详见 `insight.md` 开头。其他 list 命令按 API 默认排序。
269
269
 
270
270
  ## 异常处理
271
271
 
@@ -49,7 +49,20 @@ gangtise indicator cross-section --indicator <code> [--indicator <code2>] \
49
49
  - `--indicator`(**至少 1 个**):指标编码,来自 `search`,可重复传多个
50
50
  - `--security`(**至少 1 个**):证券代码,如 `600519.SH`(A股)/ `09992.HK`(港股)/ `AAPL.O`(美股,用 `.O`/`.N` 后缀,非 `.US`),可重复传多个。**也接受板块 ID**(`reference sector-search` 返回的 10 位 `sectorId`,如 `1000000287` 中信白酒 → 19 只成分股),代码与板块可混传,服务端取并集去重。⚠️ 中信行业码那类 9 位 ID(`100800109`)**不是** `sectorId`,传进去返 0 只
51
51
  - `--date`(**必选**):数据日期 `yyyy-MM-dd`。**CLI 把它下发为每个指标各自的 `tradeDate`**(2026-08-01 起服务端取消了根级 date)。日期语义按指标分两类——财务报表指标=报告期末(可为非交易日,实测 `2024-03-31` 可取数)、`finc_pe_ttm` / `finc_pb_mrq` 等日频估值=交易日(详见下方「日期路由」)
52
- - `--date` 必填是 CLI 的**护栏**,不是协议要求:`cross-section` 本身接受 `indicatorParamList: []`(无参指标如 `pty_op_scope` 照常返值,实测 2026-08-02)。但绝大多数指标吃 `tradeDate`,漏传就是一张空表且退出码 0,所以宁可多带一个无害参数。(`screener` 的 `--date` 也必填,同理)
52
+ - `--date` 必填是 CLI 的**护栏**,不是协议要求:`cross-section` 本身接受 `indicatorParamList: []`。但绝大多数指标吃 `tradeDate`,漏传就是一张空表且退出码 0,所以宁可多带一个。(`screener` 的 `--date` 也必填,同理)
53
+ - 🔴 **判据一句话:该指标的 `parameterList` 里有 `tradeDate` 就不用管;没有,`--date` 下发的 `tradeDate` 就可能被拒。** 按这条走,四种情形四种写法(`parameterList` 从 `indicator search --keyword <code> --format json` 读,别按 code 前缀推断):
54
+
55
+ | `parameterList` | 怎么写 |
56
+ | :--- | :--- |
57
+ | 有 `tradeDate` | 什么都不用加,`--date` 即可 |
58
+ | 无 `tradeDate`、有 `reportDate`(`is_*` 等报告期类) | 加 `--indicator-param "<code>:reportDate=2024-12-31"`;CLI 检测到就不再注入 `tradeDate` |
59
+ | 无 `tradeDate`、有别的参数 | 传那些参数,**再加一条 `--indicator-param "<code>:"`**(冒号后留空 = 不要日期)。如 `div_cash_paid_ratio` / `div_cash_yr` 要 `fiscalYear`、`pty_shr_reg`(注册资本) 只有 `currency`/`scale` |
60
+ | 无 `tradeDate`、`parameterList` 为空 | 只加 `--indicator-param "<code>:"` |
61
+
62
+ - **空冒号那条与真实参数可以共存**:`"div_cash_yr:" "div_cash_yr:fiscalYear=2025"` 两条一起给,前者只关掉日期注入,不会把 `fiscalYear` 清掉
63
+ - **哪些指标属于后两种**:已知是 `pty_*`(公司属性:注册地址 / 法定代表人 / 经营范围 / 公司简介…)与 `scr_*`(证券属性:证券简称 / ISIN / 上市市场 / 上市板块 / 上市日期…)两族,加上 `div_cash_paid_ratio` / `div_cash_yr` / `pty_shr_reg`。🔴 **这只是当前已知的快照,不是完整清单**——`indicator search` 必须给关键词、`--limit` 上限 100 且没有翻页,所以指标库无法整体枚举。**永远以 `parameterList` 为准**;要重新生成某一族用 `indicator search --keyword <前缀>_ --limit 100`,返回条数小于 100 即该族已列全
64
+ - ⚠️ **别把「有 `fiscalYear`」当成「不要 `tradeDate`」**——`frcst_pe` / `frcst_shnp` / `frcst_op_rev` 等预测类指标 `parameterList` 里**两个都是必填**,照常用 `--date` 再加一条 `fiscalYear`,**不要**加空冒号那条
65
+ - ⚠️ **也别把「有 `reportDate`」当成「不受影响」**——报告期类指标同样拒收注入的 `tradeDate`,只是解法(自己传 `reportDate`)刚好顺带关掉了注入
53
66
  - 🔴 **吃 `reportDate` 的指标必须显式传 `reportDate`**:`--indicator-param "code:reportDate=2024-12-31"`。**2026-08-15 起服务端不再接受 `tradeDate`**——只给 `--date` 会报 `100003「指标 xxx 不支持参数 tradeDate; 指标 xxx 缺少必填参数 reportDate」`(此前一版会把 `tradeDate` 归一到所在报告期,现已改为报错)。哪些指标属于这一类看 `indicator search` 的 `parameterList`:`reportDate` 标 `required: true` 且列表里没有 `tradeDate` 的就是。利润表 / 资产负债表 / 现金流量表类(`is_*` 等)都在此列;行情与估值类(`qte_*` / `finc_*`)仍用 `--date` 即可。CLI 检测到你已为某指标传了 `tradeDate` 或 `reportDate` 就不再注入 `--date`,所以补上 `--indicator-param` 就能正常取数
54
67
  - `sDate`(区间起始日)**不算**替代日期:它和 `tradeDate` 共存(`tradeDate` 是区间终点且 required),传了 `sDate` 后 `--date` 照常下发
55
68
  - `--currency`:币种 `DFT`(原始,默认)/`CNY`/`HKD`/`USD`/`EUR`/`GBP`/`JPY`/`TWD`/`MOP`/`AUD`(**大写**,2026-08-01 起服务端枚举已统一大写)
@@ -110,7 +123,8 @@ gangtise indicator screener --indicator <F1:code> [--indicator <F2:code2>] \
110
123
  - 比较:`==` `>` `<` `>=` `<=` `!=`
111
124
  - 文本:`contains` / `notcontains`(不区分大小写,**仅对 `dataType: string` 的指标有效**)
112
125
  - 逻辑:`&&` `||`,分组 `(` `)`
113
- - `--date`(**必选**):下发为**每个**指标的 `tradeDate`(已带 `tradeDate`/`reportDate` 的不覆盖)。绝大多数指标吃 `tradeDate`,漏传就是一张空表且退出码 0,所以必填。对无参指标(如 `pty_op_scope`)多挂一个 `tradeDate` 无害,因此不做例外
126
+ - `--date`(**必选**):下发为**每个**指标的 `tradeDate`(已带 `tradeDate`/`reportDate` 的不覆盖)。绝大多数指标吃 `tradeDate`,漏传就是一张空表且退出码 0,所以必填
127
+ - 🔴 **`parameterList` 里一个日期参数都没有的指标(`pty_*` / `scr_*` 静态属性两族、`div_cash_paid_ratio` / `div_cash_yr`、`pty_shr_reg`),`screener` 上当前取不到**:带 `tradeDate` 会被拒(`100003`);不带则该指标不进结果,**筛选条件等于没加、返回 0 行且不报错**——这一档最危险,看起来像「没有标的符合条件」。CLI 因此在 `screener` 上拦下 `--indicator-param "F1:"` 写法并说明原因(同样的写法在 `cross-section` 上可用)。**替代做法**:用 `indicator cross-section` 把这几列取回来,在本地筛。注意这只影响**没有任何日期参数**的指标,报告期类(`is_*` 等)在 `screener` 上给 `F1:reportDate=...` 照常可用
114
128
  - `--indicator-param`:格式是 **`F1:key=value`(按变量,不是按 code)**。引用了没绑定的变量会直接报错,不会静默丢弃
115
129
  - `--expression` 里引用未绑定的变量,CLI **本地就拦**(不发请求、不计费);服务端也会报 `100003`
116
130
  - **输出(宽表)**:同 `cross-section`,每行一只**命中**的证券,列为 `security / name / <各指标名>…`;无命中返回空表
@@ -134,9 +148,14 @@ gangtise indicator screener --indicator F1:qte_close --indicator F2:qte_close \
134
148
 
135
149
  ```bash
136
150
  # 文本筛选:白酒板块里经营范围含「酒」的公司
137
- gangtise indicator screener --indicator F1:pty_op_scope \
138
- --security 1000000287 --expression "F1 contains '酒'" \
139
- --date 2026-07-31 --format table # 19 只全部命中
151
+ # ⚠️ pty_op_scope 的 parameterList 为空,属于上面说的「screener 当前取不到」那一类,改走 cross-section 本地筛:
152
+ gangtise indicator cross-section --indicator pty_op_scope \
153
+ --indicator-param "pty_op_scope:" \
154
+ --security 1000000287 --date 2026-08-13 --format jsonl | grep 酒
155
+ # screener 上可用的文本筛选换成有日期参数的字符串指标,例如:
156
+ gangtise indicator screener --indicator F1:mgn_flag \
157
+ --security 1000000287 --expression "F1 contains '是'" \
158
+ --date 2026-08-13 --format table # 融资融券标的
140
159
  ```
141
160
 
142
161
  ```bash
@@ -6,16 +6,37 @@
6
6
 
7
7
  支持 `--rank-type` 的命令:opinion / summary / **pamirs-summary** / research / foreign-report / announcement / announcement-hk / announcement-us / foreign-opinion / independent-opinion / official-account。
8
8
 
9
- ⚠️ **`--rank-type` 的效果依赖 `--keyword`**:没有关键词时综合排序无从计算,两种取值结果一致,这不是参数失效。
10
-
11
- **`--rank-type 1`(综合排序,默认)按相关度挑选条目**,`--rank-type 2` 严格按 `publishTime` 倒序取最新的。两者从同一结果集里取的是**不同的子集**,不是同一批内容换个排法:
9
+ **`--rank-type 1`(默认)按相关度挑条目,`--rank-type 2` 严格按 `publishTime` 倒序取最新的。** 两者从同一结果集里取的是**不同的子集**,不是同一批内容换个排法。
12
10
 
13
11
  | 想要 | 用 |
14
12
  | :--- | :--- |
15
- | 最相关的内容(可以是旧的) | `--rank-type 1` + `--keyword` + `--search-type 2`(全文) |
13
+ | 最相关的内容(可以是旧的) | `--rank-type 1` + `--keyword` |
16
14
  | 最新的内容(按时间铺) | `--rank-type 2` |
17
15
 
18
- 同一个查询下,`--rank-type 1` 取回的条目时间跨度更宽(每天只挑相关度高的几条),`--rank-type 2` 则集中在最近几天(把最新的逐条铺出来)。
16
+ 🔴 **两档差别有多大取决于关键词,别拿一个关键词去判断这个参数有没有用**。同一天、同一命令实测(前 50 条比条目 ID):
17
+
18
+ | 关键词 | `research` 两档交集 | `summary` 两档交集 |
19
+ | :--- | ---: | ---: |
20
+ | `机器人` / `PCB` | 50/50(**完全相同**) | 50/50 |
21
+ | `固态电池` / `半导体设备` | 50/50 | 50/50 |
22
+ | `人形机器人` | 9/50 | 15/50 |
23
+ | `新能源汽车` | 2/50 | 0/50 |
24
+
25
+ **判据不是「词够不够具体」**(`固态电池` / `半导体设备` 都很具体,却毫无差别),而是**看 `total` 与组成词的关系**——用 `--size 1` 各查一次即可,免费:
26
+
27
+ | 关键词 | `total` | 与组成词比 | 两档 |
28
+ | :--- | --: | :--- | --: |
29
+ | `新能源汽车` | 79059 | **大于**「新能源」42406 与「汽车」48474 单独查 → 近似并集 | 差别大 |
30
+ | `人形机器人` | 12165 | ≈ 最大组成词「机器人」12164 → 也是拆词 | 差别大 |
31
+ | `固态电池` | 1156 | **小于**「固态」1541 与「电池」12569 → 近似交集 | 无差别 |
32
+
33
+ `total` 比组成词大(服务端把词拆开按 OR 找)时,候选里既有整词命中也有只中一半的,相关度拉得开,两档取的自然是不同的批;`total` 比组成词小(按整个短语找)时全是真命中、相关度并列,两档就是同一批。**这属正常,不是参数没生效**。
34
+
35
+ `新能源汽车` 那组里 `--rank-type 1` 前 50 有 47 条标题含「新能源」、22 条含完整词「新能源汽车」,`--rank-type 2` 分别是 28 条和 **0 条**——**综合排序确实在按相关度挑**。
36
+
37
+ ⚠️ **`--search-type` 不影响 `--rank-type 1` 取回哪些条目**:实测多组「命令 × 关键词」组合无一例外:`--search-type 1` 与 `2` 下 `--rank-type 1` 的前 50 条**逐位相同**,其中一组 `total` 从 1522 涨到 45257(近 30 倍)仍逐位不变。`--search-type 2`(全文)扩大的是命中总数和 `--rank-type 2` 的候选池。**所以「要最相关」不需要加 `--search-type 2`。**
38
+
39
+ ⚠️ **没有 `--keyword` 时两档结果一致**,这不是参数失效——没有关键词就无从计算相关度。
19
40
 
20
41
  ⚠️ **别用「返回结果是不是按时间倒序」判断综合排序有没有生效** —— `--rank-type 1` **挑完之后仍按时间倒序排列**,所以两种取值下返回序列都是时间单调的。要看差别就**比条目 ID 集合**,不是比排序。
21
42
  **不支持** `--rank-type` 的命令:roadshow / site-visit / strategy / forum(API 无此参数)。
@@ -271,7 +271,8 @@
271
271
  gangtise insight pamirs-summary list --keyword PCB --search-type 2 --rank-type 1 \
272
272
  --size 20 --format json
273
273
  - --search-type 2 = 全文(标题搜索是 1,命中少很多:PCB 标题 36 / 全文 113)
274
- - --rank-type 1 只在「有 keyword + 全文」时才是真正的相关度排序;要最新就用 2
274
+ - --rank-type 1 在有 keyword 时按相关度挑条目;要最新就用 2。差别多大取决于关键词本身,
275
+ --search-type 不影响 rank-type 1 挑哪些条目(这里加 2 是为了扩大命中面,不是为了排序)
275
276
  - 筛选项比 summary 少:没有 --source / --institution / --participant-role
276
277
  3. 🔴 别拉全量再本地按类别/市场分组——服务端只在你用该字段过滤时才回填标签:
277
278
  不带 --category 查,categoryList 100% 是空数组;conceptList 任何查法都是空。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {