gangtise-openapi-cli 0.34.1 → 0.36.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,40 +4,16 @@ 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;
23
11
  let pendingLoad = null;
24
- /** Which file `pendingLoad` is loading. Keyed alongside the promise because the
25
- * cache is a single-slot module global: coalescing on the promise ALONE would hand
26
- * a caller asking for file B the contents of file A. */
27
12
  let pendingLoadPath = null;
28
13
  let dirty = false;
29
14
  async function loadInto(filePath) {
30
15
  if (memoryCache && memoryCachePath === filePath)
31
16
  return memoryCache;
32
- // Coalesce concurrent first-loads. Without this, two callers that both miss the
33
- // cache each run their own readFile and each assign their OWN object to
34
- // `memoryCache`; the last assignment wins, and entries written against the
35
- // loser's reference are silently dropped. Sharing one promise means every
36
- // caller gets the same object, so merges land in one place.
37
- //
38
- // The path check is what keeps that from becoming a worse bug than the one it
39
- // fixes: same promise for the same file is deduplication, same promise for a
40
- // different file is handing back the wrong file's data.
41
17
  if (pendingLoad && pendingLoadPath === filePath)
42
18
  return pendingLoad;
43
19
  const load = (async () => {
@@ -58,7 +34,6 @@ async function loadInto(filePath) {
58
34
  return await load;
59
35
  }
60
36
  finally {
61
- // Only clear if still ours — a load for another path may have replaced it.
62
37
  if (pendingLoad === load) {
63
38
  pendingLoad = null;
64
39
  pendingLoadPath = null;
@@ -69,14 +44,7 @@ export async function readTitleCache(filePath = DEFAULT_TITLE_CACHE_PATH) {
69
44
  return loadInto(filePath);
70
45
  }
71
46
  async function flush(filePath) {
72
- // Loop rather than snapshot once. `dirty` is cleared before the snapshot, so an
73
- // entry added while we await the rename sets it again — and `writeTitleCache`
74
- // hands that caller THIS in-flight promise instead of starting a second flush.
75
- // Returning after one pass would leave that entry in memory only, never on disk.
76
47
  for (;;) {
77
- // Deciding to stop and releasing the in-flight handle must happen in ONE
78
- // synchronous block. If they were split by an await, a write landing between
79
- // them would attach to an already-resolving flush and never be written.
80
48
  if (!dirty || !memoryCache) {
81
49
  pendingWrite = null;
82
50
  return;
@@ -84,7 +52,6 @@ async function flush(filePath) {
84
52
  dirty = false;
85
53
  const snapshot = JSON.stringify(memoryCache);
86
54
  await fs.mkdir(path.dirname(filePath), { recursive: true });
87
- // Atomic-ish: write to temp file then rename (rename is atomic within a fs).
88
55
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
89
56
  await fs.writeFile(tmp, snapshot, { encoding: "utf8", mode: 0o600 });
90
57
  try {
@@ -96,12 +63,6 @@ async function flush(filePath) {
96
63
  }
97
64
  }
98
65
  }
99
- /**
100
- * Trim `merged` down to `cap` entries. The freshly-written ids (`freshKeys`) are
101
- * kept first since those are what the user just listed and is most likely to
102
- * download; remaining capacity is filled from the rest. Returns `merged` as-is
103
- * when already within the cap.
104
- */
105
66
  function capTitles(merged, freshKeys, cap) {
106
67
  if (Object.keys(merged).length <= cap)
107
68
  return merged;
@@ -116,8 +77,6 @@ function capTitles(merged, freshKeys, cap) {
116
77
  }
117
78
  }
118
79
  if (n < cap) {
119
- // Newest-inserted keys sit at the END of the merged object — fill in reverse so
120
- // eviction drops the oldest entries, not the batch the user just listed yesterday.
121
80
  const rest = Object.keys(merged);
122
81
  for (let i = rest.length - 1; i >= 0 && n < cap; i--) {
123
82
  const k = rest[i];
@@ -129,12 +88,6 @@ function capTitles(merged, freshKeys, cap) {
129
88
  }
130
89
  return out;
131
90
  }
132
- /**
133
- * Keep the cache bounded on every write: drop endpoint entries past their TTL
134
- * (lookups already ignore them) and cap each endpoint's titles. The just-written
135
- * endpoint keeps its newest ids first; others over the cap are trimmed too, so a
136
- * pre-existing oversized file shrinks on the next write.
137
- */
138
91
  function pruneCache(data, freshEndpoint, freshKeys) {
139
92
  const now = Date.now();
140
93
  for (const key of Object.keys(data)) {
@@ -158,16 +111,6 @@ export async function writeTitleCache(endpoint, titles, filePath = DEFAULT_TITLE
158
111
  data[endpoint] = { titles: { ...existing, ...titles }, ts: Date.now() };
159
112
  pruneCache(data, endpoint, Object.keys(titles));
160
113
  dirty = true;
161
- // Coalesce concurrent writes: the in-flight flush picks up everything dirty.
162
- //
163
- // `flush` clears `pendingWrite` itself, in the same synchronous block as the
164
- // `if (!dirty || !memoryCache)` test that decides to stop looping. Clearing it out
165
- // here — in a `finally` after `await flush(...)` — would leave a gap of microtasks
166
- // between "flush decided nothing is left" and "flush is no longer in flight": a
167
- // write landing in that gap sets `dirty`, sees a non-null `pendingWrite`, and
168
- // attaches to a promise that is already resolving, so its entry never reaches
169
- // disk while its `await` still resolves normally. Same silent loss the loop was
170
- // added to fix, just a narrower window.
171
114
  if (pendingWrite)
172
115
  return pendingWrite;
173
116
  pendingWrite = (async () => {
@@ -175,16 +118,6 @@ export async function writeTitleCache(endpoint, titles, filePath = DEFAULT_TITLE
175
118
  await flush(filePath);
176
119
  }
177
120
  catch (error) {
178
- // flush throws before reaching its own clear, so release the handle here.
179
- // Safe to null unconditionally: nothing else can have replaced it, since a
180
- // non-null pendingWrite is exactly what stops another flush from starting.
181
- //
182
- // ⚠️ Only the SUCCESS path is atomic. A write landing between the throw and
183
- // this line still attaches to the rejecting promise and never reaches disk;
184
- // it recovers on the next successful write (which re-serialises the whole
185
- // in-memory cache), and is lost only if no write ever follows. Left as-is
186
- // deliberately — retrying against a filesystem that is already failing is
187
- // worse than deferring to the next write.
188
121
  pendingWrite = null;
189
122
  throw error;
190
123
  }
@@ -212,14 +145,10 @@ export function extractTitles(items, cache) {
212
145
  }
213
146
  return titles;
214
147
  }
215
- /** Test-only hook to reset the in-memory snapshot between cases. */
216
148
  export function __resetTitleCacheForTests() {
217
149
  memoryCache = null;
218
150
  memoryCachePath = null;
219
151
  pendingWrite = null;
220
- // Must include the load handles: a reset that leaves `pendingLoad` set would hand
221
- // the next readTitleCache the pre-reset promise, breaking the "force a fresh read
222
- // from disk" contract this helper exists to provide.
223
152
  pendingLoad = null;
224
153
  pendingLoadPath = null;
225
154
  dirty = false;
@@ -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.1";
1
+ export const CLI_VERSION = "0.36.0";
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gangtise-openapi
3
- version: "0.34.1"
3
+ version: "0.36.0"
4
4
  description: |-
5
5
  通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
6
6
 
@@ -24,7 +24,7 @@ description: |-
24
24
  1. **`--format json`**:列表/数据类必加。AI 内容生成(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `*-check`)也加 json,但呈现时**直接取 `content` 字段**,不要展示 JSON 包装层。
25
25
  2. **opaque ID**:先读 `references/lookup-ids.md`;找不到再按类型查:行业/区域/公告分类/城市 → `reference constant-list --category <分类>`(分类代码用 `reference constant-category` 查);题材 → `reference concept-search --keyword <名>`;板块 → `reference sector-search --keyword <名>`;申万 `--gts-code` 行业代码全量 → `sector-search --keyword 申万一级行业指数` 取指数数据板块层级的 sectorId 再 `sector-constituents`;券商/牵头/观点机构(按名称找 ID)→ `reference institution-search --keyword <名> [--category ...]`(服务端搜索,返回 `institutionId` + `usageScopes` 标明该 ID 用于哪个接口的哪个参数;覆盖 `--broker` / `--institution` 全部 5 类机构,含 `foreignOpinionInstitution`)——仅当要**全量枚举**时才用本地表 `gangtise lookup broker-org/meeting-org list`(institution-search 是搜索型:top≤10、非全量)。**绝不猜测**。
26
26
  3. **公司名 → 证券代码**:先查下方速查表(5 只 mega-cap),其余一律 `gangtise reference securities-search --keyword <名> --category stock` 取 `list[0].gtsCode`。
27
- 4. **时间格式**:datetime `"YYYY-MM-DD HH:mm:ss"`(引号包裹),date `YYYY-MM-DD`。
27
+ 4. **时间格式**:datetime `"YYYY-MM-DD HH:mm:ss"`(引号包裹),date `YYYY-MM-DD`(`YYYY/MM/DD`、`YYYYMMDD` 也收,会归一;**年在后写法一律拒绝**)。
28
28
  5. **多值参数**:优先重复传(最稳、最明确):`--security 600519.SH --security 000858.SZ`。CLI 也支持半/全角逗号分隔(`args.ts` 为语音输入容错),但重复传不易被 shell 吞。
29
29
  6. **K 线"最近 N 条"**:必须用 `--start-date`/`--end-date` 拉日期范围,从结果按 `tradeDate` 取尾部最近 N 条。**不要只用 `--limit N`**(截取的是窗口开头)。
30
30
  6.1. **日 K 仅历史**:`day-kline` **不返回盘中实时数据**。当日数据入库时间:A 股 ~15:30 / 港股 ~16:30 / 美股 ~07:00(北京时间)。需要盘中快照请走 `quote realtime`。
@@ -74,9 +74,12 @@ description: |-
74
74
  - **按条(观点/含详情类 list)**:independent-opinion list 与 `ai security-clue` 5;roadshow/site-visit/strategy/forum list 20;opinion / foreign-opinion list 30;`fundamental earning-forecast` 0.5;`ai stock-summary` 3(无看点的证券不返回也不扣);`alternative edb-data` 30
75
75
  - **各 download(/篇)**:announcement / official-account / research 10;announcement-hk / announcement-us 20;independent-opinion 30;summary / foreign-report / my-conference 50;`performance-calendar download` A 股 10 / 港美股 20
76
76
  - **按页**:`tool file-parse` 0.8/页,**提交(`--file`)时按实际页数一次性扣**,取结果(`file-parse-check`)免费——50 页 PDF = 40 积分,别重复提交同一文件
77
- - 🔴 **按次贵**:`ai knowledge-batch` 10、`management-discuss-*` 10;AI Agent(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `earnings-review` / `viewpoint-debate` / `theme-tracking`)**50/次**;`ai hot-topic` 50/篇
77
+ - 🔴 **按次贵**:`ai knowledge-batch` 10、`management-discuss-*` 10;AI Agent(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `earnings-review` / `viewpoint-debate` / `theme-tracking`)**50/次**
78
+ - 🔴 **`ai hot-topic` 50/篇,按返回条数计**(不是按调用次数)。📌 **一「篇」= 一整份报告**(一份早报 / 午报 / 盘中快报 / 晚报),**不是报告里的一条话题**——一份报告通常包含多条热点话题。所以 `--size 20` 的一页 = 最多 20 份报告 = 1000 积分,**先用 `--start-date`/`--end-date` + `--category` 收窄再拉**,别省略 `--size` 直接全量。**可查的历史范围跟账号权限走**(试用档是滚动的「当前 −1 个月」,正式/定制档更长)——超出范围的日期返回空结果而不是报错,拿到空先想想是不是撞了权限窗口
78
79
  - 🔴 **极贵**:`alternative concept-info` / `concept-securities` **500/次**
79
- - ⚠️ **同参数重复调用不免费**:按次计费无缓存命中豁免(2026-07-11 实测 `one-pager` 重复调用每次扣分,即使秒回缓存内容)——生成类结果拿到后自行留存复用,别为"刷新"重调;CLI 已对上述 🔴 贵档端点关闭 5xx/超时自动重放,50/篇 的 `summary` / `foreign-report` / `my-conference` download、单价未公布但保守同档处理的 `pamirs-summary` download、以及 `tool file-parse` 的提交,同样不重放(共 18 个端点),正是为防重复扣分
80
+ - ✅ **按篇 / 按条计费的接口,没查到内容就不扣分**(空结果 = 0 积分):各 download(`pamirs-summary` 除外,见 ②)、`ai hot-topic`(50/篇)、`ai stock-summary`(3/条,无看点总结的个股不进返回列表也不计费)。**所以「先小范围试探再放大」是安全的**——先用窄条件确认能查到东西,再扩范围。⚠️ **两类不适用**:① **按次计费的**——`ai knowledge-batch` / `management-discuss-*`、AI Agent 那批 50/次、`alternative concept-info` / `concept-securities` 500/次,不管有没有内容都扣,超时报错也可能已经扣过;② **单价未公布的**——`pamirs-summary`(见下),别据此假定
81
+ - ⚠️ **同参数重复调用不免费**:按次计费的那批无缓存命中豁免(2026-07-11 实测 `one-pager` 重复调用每次扣分,即使秒回缓存内容);**按篇/按条的也一样**——重拉同一批 `hot-topic` 就是按条数再计一次费。生成类与列表结果拿到后自行留存复用,别为「刷新」重调
82
+ - ⚠️ **贵档端点超时/5xx 不自动重放**(共 18 个:AI Agent 那批 + `ai knowledge-batch` / `management-discuss-*` / `hot-topic`、`alternative concept-info`·`concept-securities`、50/篇 的 `summary`·`foreign-report`·`my-conference` download 与同档处理的 `pamirs-summary` download、`tool file-parse` 提交)。**理由是重放会重复扣分**——服务端可能已经执行并计费,重发按次计费的会再扣一次,重发按篇/按条计费的会把**已交付的行**再计一次;两种都亏。仅连接失败、429 与 token 自愈会重试
80
83
  - **按单元格**:`indicator cross-section` / `time-series` / `screener`(A股 0.05 / 港股 0.1 / 美股 0.2 积分每 100 单元格;screener 按**筛选前**范围计费,见 `indicator.md`);`ai knowledge-resource-download` 按下游资源计费
81
84
  - **单价未公布**:`insight pamirs-summary list` / `download`——spec 只写了「需购买专家纪要数据库」这个准入门槛,没给单次价格。**别据此假定免费**;大批量拉取前先小量试,或向平台确认
82
85
 
@@ -263,9 +266,9 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
263
266
  | 最新一期 / 最新报告期(财报) | — | — | 省略 `--fiscal-year`,传 `--period latest`(默认) |
264
267
  | 最新观点 / 今日观点 | 1 天范围 + `--rank-type 2` | — | — |
265
268
 
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` 分隔)。
269
+ 日期参数**按参数名判断、不按命令组**(命令组会误导——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` 首选,`YYYY/MM/DD`/`YYYYMMDD` 也收),覆盖 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`)用年在前日期 + `[ 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
270
 
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 默认排序。
271
+ 支持排序切换的命令: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` 取回哪些条目**(同一关键词下,两档的 `--rank-type 1` 结果相同——即使 `--search-type 2` 让 `total` 大出一个数量级),**要相关度不必加 `--search-type 2`**;它扩大的是命中总数和 `--rank-type 2` 的候选池。🔴 **两档差别有多大取决于关键词,别拿一个词判断参数有没有用**:有些关键词下两档返回同一批,有些交集接近 0,判据见 `insight.md` 开头。⚠️ 综合排序**挑完之后仍按时间倒序排列**,所以别用「结果是不是时间倒序」判断它有没有生效,要比条目 ID。没有 `--keyword` 时两者无差异,属正常。详见 `insight.md` 开头。其他 list 命令按 API 默认排序。
269
272
 
270
273
  ## 异常处理
271
274
 
@@ -305,7 +308,7 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
305
308
  | ✅ `140002` | 终态参数错:AI 异步生成失败,或 `indicator` 的指标必填参数缺失 / 枚举越界 / 表达式语法错(实测 2026-08-02) | **不重试**(终态码) | 按 msg 改参数重提;EDE 的参数名与枚举读 `indicator search --format json` 的 `parameterList` |
306
309
 
307
310
  **⚠️ 几类"不报错"的坑(最难发现,逐条都关系到拿没拿到对的数)**
308
- - **日期只写 `YYYY-MM-DD`、时间只写 `YYYY-MM-DD HH:mm:ss`(或 10/13 位时间戳)**。CLI v0.28.0 起 date 与 datetime 两类(含所有 insight/vault 透传参数)都在发请求前校验,其余写法直接报 `ValidationError`。接口本身能接受多种格式,但**「年在后」写法(`01-07-2026`)存在月/日歧义**——会按月在前读作 1 月 7 日,本意写 7 月 1 日的就拿到差半年的数据且不报错。**CLI 只放行 `YYYY-MM-DD` 就是为了堵掉这个歧义,这是有意的**;绕过 CLI 直连接口时务必自己用标准格式
311
+ - **日期用「年在前」写法**:`YYYY-MM-DD`(首选)、`YYYY/MM/DD`、`YYYYMMDD` 三种 CLI 都收,并**统一归一成 `YYYY-MM-DD` 再发出**;时间加 `[ HH:mm[:ss]]`(秒可省、空格或 `T` 分隔)或用 10/13 位时间戳。🔴 **「年在后」写法(`01-07-2026`、`07/01/2026`)CLI 直接报 `ValidationError`,这是有意的**——接口本身会解析它,且**一律按美式「月在前」**(`01-07-2026` = 1 月 7 日,`07-01-2026` = 7 月 1 日)。按欧洲/国际习惯写 `01-07-2026` 想表达「7 月 1 日」的,会拿到差半年的数据且**不报错**;本地拒掉才有信号,还省一次请求与计费。绕过 CLI 直连接口时务必自己统一用 `YYYY-MM-DD`
309
312
  - **财报接口的日期按「报告期末」过滤**,不是公告日:`fundamental balance-sheet` 等的 `--start-date`/`--end-date` 匹配的是 `endDate` 字段(如 `20200630`);公告日看 `earliestAnncDate`(首次公告日,做时点对齐用这个)而不是 `announcementDate`。**查某期财报要传季度末日期**(`2020-06-30` / `2020-03-31` / `2020-09-30` / `2020-12-31`);传 `2020-07-01` 这类非报告期日期会返回 0 行,属正常行为,不是没数据
310
313
  - **非法证券代码**:`quote day-kline` / `realtime` / `minute-kline` / `fund-flow` 与 Fundamental 系都会报 `120001`,按报错核对后缀即可。⚠️ **三个已下线的旧端点 `quote day-kline-hk` / `day-kline-us` / `index-day-kline` 则返回 `total:0`**,与"该票该区间真无数据"无法区分——**用这三个拿到空结果时先回头核对代码后缀**。它们的能力已并入统一 `day-kline`(支持 A 股 / 港股 / 美股 / 交易所指数 / 概念指数 `.GT` / 申万行业指数 `.SWI`),新代码直接用 `day-kline`
311
314
  - **枚举值拼错的后果按端点不同,且部分端点不报错**:纪要 `summary`、三个公告 list、路演 `roadshow`、调研 `site-visit` 的 `--search-type` / `--rank-type` / `--category` / `--market` 等传非法值会报 `100005`;而 `insight research`(内资研报)、`foreign-report`(外资研报)、`official-account`、`opinion` 上非法值被**静默忽略**,该筛选按未传处理。🔴 **最坏的一种**:`research` / `foreign-report` 上非法的 `--search-type` 会**连带吞掉 `--keyword`**——`research list --keyword 茅台 --search-type 99` 拿到的是**未经筛选的全库**,与不传 `--keyword` 的结果一致,而不是搜索结果。**v0.32.0 起 CLI 本地拦截**:全部 `--search-type` / `--rank-type`(19 处)、全部 `--file-type`(9 处下载,`foreign-report` 为 1–4、其余 1–2)、`pamirs-summary` 的 `--category` / `--market`、`--top` 上限、以及 `reference securities-search` / `institution-search` / `official-account-search` 的 `--category`。**仍未覆盖**:`insight research/summary --category`、`--market`、`--source`、`--llm-tag` 等仍是自由字符串,拼错不报错也不生效——这些要自己核对。**拼错的筛选条件会伪装成"结果正常",枚举拼写要自己保证**
@@ -2,7 +2,7 @@
2
2
 
3
3
  注意:`ai one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `viewpoint-debate-check` / `earnings-review-check` 返回 `{content: "markdown文本"}`;这类命令仍然加 `--format json`,但呈现给用户时直接取 `content` 字段,不要展示 JSON 包装层。
4
4
 
5
- **⏱ 超时与重复扣分**:7 个 agent 类(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `theme-tracking` / `management-discuss-*`)CLI 已内置 120s 超时下限,**无需前缀**;`stock-summary` / `hot-topic` 首次生成也常 >30s,仍建议前置 `GANGTISE_TIMEOUT_MS=120000`。自 v0.26.0 起**贵档端点超时/5xx 不再自动重试**(重放=重复扣分)——超时报错后内容可能已在服务端生成并扣费,同参数再调会**再扣一次**(实测按次计费、无缓存命中豁免),所以一次调用给足超时比失败重跑省钱;拿到的生成内容自行留存复用,别为"刷新"重调。`earnings-review` / `viewpoint-debate` 是异步——用 `--wait`(工具超时 ≥360s)或 `*-check` 轮询,不吃这个超时。
5
+ **⏱ 超时与重复扣分**:7 个 agent 类(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `theme-tracking` / `management-discuss-*`)CLI 已内置 120s 超时下限,**无需前缀**;`stock-summary` / `hot-topic` 首次生成也常 >30s,仍建议前置 `GANGTISE_TIMEOUT_MS=120000`。自 v0.26.0 起**贵档端点超时/5xx 不再自动重试**(重放=重复扣分)——超时报错后内容可能已在服务端生成并扣费,同参数再调会**再扣一次**(`one-pager` 等生成类实测按次计费、无缓存命中豁免),所以一次调用给足超时比失败重跑省钱;拿到的生成内容自行留存复用,别为"刷新"重调。`earnings-review` / `viewpoint-debate` 是异步——用 `--wait`(工具超时 ≥360s)或 `*-check` 轮询,不吃这个超时。
6
6
 
7
7
  ---
8
8
 
@@ -126,6 +126,8 @@ gangtise ai hot-topic [--start-date <date>] [--end-date <date>] [--category <nam
126
126
  - `--category`:`morningBriefing` 早报 | `noonBriefing` 午报 | `afternoonFlash` 盘中快报 | `eveningBriefing` 晚报(可重复,默认全部)
127
127
  - `--with-related-securities` / `--with-close-reading`:默认开启;`--no-with-related-securities` / `--no-with-close-reading` 显式排除(响应里相应字段置空)
128
128
  - 自动翻页,单页最大 20
129
+ - 🔴 **计费 50/篇,按返回条数计**(不是按调用次数)。📌 **一「篇」= 一整份报告**(一份早报 / 午报 / 盘中快报 / 晚报),**不是报告里的一条话题**——一份报告通常包含多条热点话题,`--category` 选的也是报告类型而不是话题。所以一页 20 条 = 20 份报告 = 1000 积分:**先用 `--start-date`/`--end-date` + `--category` 把范围收窄,再考虑要不要全量**(省略 `--size` 即拉全量)
130
+ - **可查的历史范围跟账号权限走**:试用档是滚动的「当前 −1 个月」,正式/定制档更长。超出范围的日期**返回空结果、不报错**——拿到空先确认是不是撞了本账号的窗口,别当成「那天没有报告」
129
131
 
130
132
  ## 管理层讨论-财报 `ai management-discuss-announcement`
131
133
 
@@ -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`)必须显式声明「不要日期」**:写 `--indicator-param "F1:"`(冒号后什么都不写)。不写的话 `--date` 会给它注入 `tradeDate`,而这些指标不接受该参数,**整条请求**报 `100003 指标 xxx 不支持参数 tradeDate`。`F1:` 与真参数可组合,`div_cash_*` 要同时给 `F1:` 和 `F1:fiscalYear=2025`。这与 `cross-section` 的 `"code:"` 是同一个写法。注意只有**没有任何日期参数**的指标需要,报告期类(`is_*` 等)给 `F1:reportDate=...` 即可
114
128
  - `--indicator-param`:格式是 **`F1:key=value`(按变量,不是按 code)**。引用了没绑定的变量会直接报错,不会静默丢弃
115
129
  - `--expression` 里引用未绑定的变量,CLI **本地就拦**(不发请求、不计费);服务端也会报 `100003`
116
130
  - **输出(宽表)**:同 `cross-section`,每行一只**命中**的证券,列为 `security / name / <各指标名>…`;无命中返回空表
@@ -134,9 +148,15 @@ gangtise indicator screener --indicator F1:qte_close --indicator F2:qte_close \
134
148
 
135
149
  ```bash
136
150
  # 文本筛选:白酒板块里经营范围含「酒」的公司
151
+ # ⚠️ pty_op_scope 的 parameterList 为空 → 必须带 "F1:" 声明它不吃日期,否则整条请求报 100003
137
152
  gangtise indicator screener --indicator F1:pty_op_scope \
153
+ --indicator-param "F1:" \
138
154
  --security 1000000287 --expression "F1 contains '酒'" \
139
- --date 2026-07-31 --format table # 19 只全部命中
155
+ --date 2026-08-13 --format table
156
+ # 有日期参数的字符串指标不需要 "F1:",直接筛:
157
+ gangtise indicator screener --indicator F1:mgn_flag \
158
+ --security 1000000287 --expression "F1 contains '是'" \
159
+ --date 2026-08-13 --format table # 融资融券标的
140
160
  ```
141
161
 
142
162
  ```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` 结果相同——即使 `--search-type 2` 把 `total` 放大一个数量级,取回的条目仍是同一批。`--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.1",
3
+ "version": "0.36.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {