gangtise-openapi-cli 0.43.1 → 0.44.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.
Files changed (40) hide show
  1. package/README.md +86 -15
  2. package/dist/src/cli.js +29 -18
  3. package/dist/src/commands/ai.js +20 -29
  4. package/dist/src/commands/auth.js +5 -4
  5. package/dist/src/commands/bond.js +5 -4
  6. package/dist/src/commands/fund.js +100 -0
  7. package/dist/src/commands/fundamental.js +2 -0
  8. package/dist/src/commands/groups.js +14 -0
  9. package/dist/src/commands/indicator.js +5 -5
  10. package/dist/src/commands/insight.js +3 -4
  11. package/dist/src/commands/raw.js +4 -3
  12. package/dist/src/commands/shared.js +35 -6
  13. package/dist/src/commands/tool.js +2 -2
  14. package/dist/src/commands/vault.js +30 -30
  15. package/dist/src/core/args.js +23 -7
  16. package/dist/src/core/asyncContent.js +1 -1
  17. package/dist/src/core/auth.js +2 -10
  18. package/dist/src/core/client.js +67 -19
  19. package/dist/src/core/commandBodies.js +1 -27
  20. package/dist/src/core/config.js +9 -5
  21. package/dist/src/core/download.js +47 -10
  22. package/dist/src/core/endpoints.js +195 -0
  23. package/dist/src/core/errors.js +1 -1
  24. package/dist/src/core/output.js +103 -72
  25. package/dist/src/core/printer.js +10 -5
  26. package/dist/src/core/quoteSharding.js +28 -4
  27. package/dist/src/core/rowSink.js +24 -10
  28. package/dist/src/core/titleCache.js +2 -10
  29. package/dist/src/core/transport.js +25 -7
  30. package/dist/src/core/updateCheck.js +3 -9
  31. package/gangtise-openapi/SKILL.md +52 -16
  32. package/gangtise-openapi/references/commands/fund.md +192 -0
  33. package/gangtise-openapi/references/commands/fundamental.md +2 -2
  34. package/gangtise-openapi/references/commands/insight.md +2 -2
  35. package/gangtise-openapi/references/commands/quote.md +1 -1
  36. package/gangtise-openapi/references/commands/tool.md +1 -1
  37. package/gangtise-openapi/references/commands/vault.md +1 -1
  38. package/gangtise-openapi/references/errors.md +4 -4
  39. package/gangtise-openapi/references/response-schema.md +10 -0
  40. package/package.json +1 -1
@@ -6,10 +6,10 @@ import { pipeline } from "node:stream/promises";
6
6
  import { FormData, request } from "undici";
7
7
  import { credentialFingerprint, isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
8
8
  import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from "./errors.js";
9
- import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
+ import { COSTLY_FETCH_CREDITS, ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
10
10
  import { getLookupData } from "./lookupData/index.js";
11
11
  import { stagingPath } from "./output.js";
12
- import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, withRetry } from "./transport.js";
12
+ import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
13
13
  import { attachRowSink } from "./rowSink.js";
14
14
  import { markIncomplete } from "./exitStatus.js";
15
15
  const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
@@ -18,7 +18,6 @@ function hasExpectedShape(expects, payload) {
18
18
  return Array.isArray(payload);
19
19
  return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
20
20
  }
21
- export const COSTLY_FETCH_CREDITS = 1000;
22
21
  const PROBE_ABOVE_CREDITS = 50;
23
22
  const OFFSET_REFUSAL_CODES = new Set(["140002", "100006"]);
24
23
  function readEmptyListAsArray(page) {
@@ -46,6 +45,7 @@ export class GangtiseClient {
46
45
  allowCostlyFetch = false;
47
46
  refreshPromise = null;
48
47
  memoCache = null;
48
+ loginToken = null;
49
49
  envTokenInvalidated = false;
50
50
  rowSinkClaimed = false;
51
51
  constructor(config, rowSink) {
@@ -98,6 +98,7 @@ export class GangtiseClient {
98
98
  issuedFor: credentialFingerprint(credentials.accessKey, this.config.baseUrl),
99
99
  };
100
100
  this.memoCache = cache;
101
+ this.loginToken = accessToken;
101
102
  try {
102
103
  await writeTokenCache(this.config.tokenCachePath, cache);
103
104
  }
@@ -108,23 +109,45 @@ export class GangtiseClient {
108
109
  return accessToken;
109
110
  }
110
111
  async refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization) {
111
- if (useAuth
112
- && !authState.retried
112
+ if (!(useAuth
113
113
  && error instanceof ApiError
114
114
  && error.code
115
115
  && AUTH_RETRY_CODES.has(error.code)
116
116
  && this.config.accessKey
117
- && this.config.secretKey) {
118
- authState.retried = true;
119
- this.envTokenInvalidated = true;
117
+ && this.config.secretKey))
118
+ return;
119
+ if (authState.usedLoginToken)
120
+ return;
121
+ this.envTokenInvalidated = true;
122
+ const replay = () => markAuthReplay(new ApiError(error.message, error.code, error.statusCode, error.details));
123
+ const tryMemory = () => {
120
124
  const memoToken = this.memoCache && isTokenCacheValid(this.memoCache) ? normalizeToken(this.memoCache.accessToken) : null;
121
- const alreadyRefreshed = memoToken !== null && usedAuthorization !== undefined && usedAuthorization !== memoToken;
122
- if (!alreadyRefreshed) {
123
- this.memoCache = null;
124
- await this.getAuthorizationHeader(true);
125
+ if (memoToken === null || usedAuthorization === undefined || memoToken === usedAuthorization)
126
+ return;
127
+ if (memoToken === this.loginToken) {
128
+ authState.usedLoginToken = true;
129
+ throw replay();
130
+ }
131
+ if (!authState.usedUnprovenToken) {
132
+ authState.usedUnprovenToken = true;
133
+ throw replay();
134
+ }
135
+ };
136
+ tryMemory();
137
+ if (!authState.usedUnprovenToken) {
138
+ const expected = credentialFingerprint(this.config.accessKey, this.config.baseUrl);
139
+ const disk = await readTokenCache(this.config.tokenCachePath);
140
+ tryMemory();
141
+ if (disk && isTokenCacheValid(disk, undefined, expected) && normalizeToken(disk.accessToken) !== usedAuthorization) {
142
+ this.memoCache = disk;
143
+ authState.usedUnprovenToken = true;
144
+ throw replay();
125
145
  }
126
- throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
127
146
  }
147
+ this.memoCache = null;
148
+ await this.getAuthorizationHeader(true);
149
+ authState.usedLoginToken = true;
150
+ throw replay();
128
151
  }
129
152
  buildUrl(path) {
130
153
  const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
@@ -193,15 +216,18 @@ export class GangtiseClient {
193
216
  }
194
217
  const windowRoom = maxWindow === undefined ? Infinity : maxWindow - startFrom;
195
218
  const billing = endpoint.billing;
196
- const guarded = requestedSize === undefined && billing?.per === "row" && !this.allowCostlyFetch;
219
+ const guarded = billing?.per === "row" && !this.allowCostlyFetch
220
+ && (requestedSize === undefined || requestedSize * billing.price > COSTLY_FETCH_CREDITS);
197
221
  const refuseIfCostly = (reportedTotal, fetched) => {
198
222
  if (!guarded || !billing)
199
223
  return;
200
- const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom);
224
+ const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom, requestedSize ?? Infinity);
201
225
  const estimatedCredits = Math.round(rows * billing.price * 100) / 100;
202
226
  if (estimatedCredits <= COSTLY_FETCH_CREDITS)
203
227
  return;
204
- throw new ValidationError(`fetching all ${rows} rows of ${endpoint.key} would cost about ${estimatedCredits} credits (${billing.price} per row), above the ${COSTLY_FETCH_CREDITS}-credit guard for a fetch without --size. Only ${fetched} row(s) were fetched, to learn the total. Pass --size N for a bounded subset, or --yes to fetch them all.`);
228
+ const what = requestedSize === undefined ? `all ${rows} rows` : `${rows} rows (--size ${requestedSize})`;
229
+ const escape = requestedSize === undefined ? "Pass --size N for a bounded subset" : "Pass a smaller --size";
230
+ throw new ValidationError(`fetching ${what} of ${endpoint.key} would cost about ${estimatedCredits} credits (${billing.price} per row), above the ${COSTLY_FETCH_CREDITS}-credit guard. Only ${fetched} row(s) were fetched, to learn the total. ${escape}, or --yes to fetch them all.`);
205
231
  };
206
232
  let probe;
207
233
  let probed = false;
@@ -215,7 +241,7 @@ export class GangtiseClient {
215
241
  else {
216
242
  refuseIfCostly(probe.total, probe.list.length);
217
243
  probeRows = probe.list.length;
218
- probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom);
244
+ probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom, requestedSize ?? Infinity);
219
245
  }
220
246
  }
221
247
  const firstPageSize = probed ? 1 : Math.min(requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize), windowRoom);
@@ -446,7 +472,7 @@ export class GangtiseClient {
446
472
  }
447
473
  const dispatcher = getDispatcher();
448
474
  const url = this.buildUrl(endpoint.path);
449
- const authState = { retried: false };
475
+ const authState = {};
450
476
  const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
451
477
  return withRetry(async () => {
452
478
  const isUpload = endpoint.kind === 'upload';
@@ -467,6 +493,7 @@ export class GangtiseClient {
467
493
  body: endpoint.method === 'GET' ? undefined : (isUpload ? body : JSON.stringify(body ?? {})),
468
494
  headersTimeout: timeoutMs,
469
495
  bodyTimeout: timeoutMs,
496
+ signal: AbortSignal.timeout(timeoutMs * (isUpload ? TRANSFER_TOTAL_FACTOR : JSON_TOTAL_FACTOR)),
470
497
  dispatcher,
471
498
  });
472
499
  const encoding = response.headers['content-encoding'];
@@ -547,11 +574,12 @@ export class GangtiseClient {
547
574
  Object.entries(query).forEach(([key, value]) => {
548
575
  url.searchParams.set(key, String(value));
549
576
  });
550
- const authState = { retried: false };
577
+ const authState = {};
551
578
  const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
552
579
  return withRetry(async () => {
553
580
  const authorization = await this.getAuthorizationHeader();
554
581
  const startedAt = Date.now();
582
+ const signal = AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR);
555
583
  let currentUrl = url;
556
584
  let auth = authorization;
557
585
  const isPost = endpoint.method === 'POST';
@@ -563,6 +591,7 @@ export class GangtiseClient {
563
591
  body: isPost ? JSON.stringify(body ?? {}) : undefined,
564
592
  headersTimeout: timeoutMs,
565
593
  bodyTimeout: timeoutMs,
594
+ signal,
566
595
  dispatcher,
567
596
  });
568
597
  for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
@@ -580,6 +609,7 @@ export class GangtiseClient {
580
609
  headers: auth ? { Authorization: auth } : {},
581
610
  headersTimeout: timeoutMs,
582
611
  bodyTimeout: timeoutMs,
612
+ signal,
583
613
  dispatcher,
584
614
  });
585
615
  }
@@ -675,6 +705,23 @@ export class GangtiseClient {
675
705
  },
676
706
  });
677
707
  }
708
+ refuseIfEstimatedCostly(endpoint, body) {
709
+ const billing = endpoint.billing;
710
+ if (!endpoint.estimateBilledUnits || !billing || this.allowCostlyFetch)
711
+ return;
712
+ let units;
713
+ try {
714
+ units = endpoint.estimateBilledUnits(body && typeof body === "object" ? body : {});
715
+ }
716
+ catch (error) {
717
+ const why = error instanceof Error ? error.message : String(error);
718
+ throw new ValidationError(`${endpoint.key} could not be priced before sending (${why}), and it is billed per ${billing.per} with no upper bound. Fix the request, or pass --yes to send it as it is.`);
719
+ }
720
+ const credits = Math.ceil(units * billing.price);
721
+ if (credits <= COSTLY_FETCH_CREDITS)
722
+ return;
723
+ throw new ValidationError(`${endpoint.key} would cost about ${credits} credits (${billing.price} per ${billing.per}, about ${units} estimated from the requested date range), above the ${COSTLY_FETCH_CREDITS}-credit guard. Narrow the date range, or pass --yes to send it.`);
724
+ }
678
725
  async call(endpointKey, body, query, options) {
679
726
  const endpoint = ENDPOINTS[endpointKey];
680
727
  if (!endpoint) {
@@ -686,6 +733,7 @@ export class GangtiseClient {
686
733
  if (endpoint.kind === 'download') {
687
734
  return this.download(endpoint, query ?? {}, options, body);
688
735
  }
736
+ this.refuseIfEstimatedCostly(endpoint, body);
689
737
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
690
738
  return this.requestPaginated(endpoint, body);
691
739
  }
@@ -1,4 +1,4 @@
1
- import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators, parseSize } from "./args.js";
1
+ import { maybeArray, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators } from "./args.js";
2
2
  import { ValidationError } from "./errors.js";
3
3
  import { QUOTE_MAX_LIMIT } from "./quoteSharding.js";
4
4
  export function buildQuoteKlineBody(options) {
@@ -10,32 +10,6 @@ export function buildQuoteKlineBody(options) {
10
10
  fieldList: maybeArray(options.field),
11
11
  };
12
12
  }
13
- export function buildWechatMessageListBody(options) {
14
- return {
15
- from: parseFrom(options.from),
16
- size: parseSize(options.size),
17
- startTime: options.startTime,
18
- endTime: options.endTime,
19
- keyword: options.keyword,
20
- securityList: maybeArray(options.security),
21
- wechatGroupIdList: maybeArray(options.wechatGroupId),
22
- industryIdList: maybeArray(options.industry),
23
- categoryList: maybeArray(options.category),
24
- tagList: maybeArray(options.tag),
25
- };
26
- }
27
- export function buildWechatChatroomListBody(options) {
28
- return {
29
- from: parseFrom(options.from),
30
- size: parseSize(options.size),
31
- roomName: options.roomName.length > 0 ? options.roomName.join(",") : undefined,
32
- };
33
- }
34
- export function buildStockPoolStocksBody(options) {
35
- return {
36
- poolIdList: options.poolId?.length ? options.poolId : ["all"],
37
- };
38
- }
39
13
  const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate"]);
40
14
  function assertParamCodesBound(groups, codes) {
41
15
  if (!groups?.length)
@@ -12,14 +12,18 @@ export function loadConfig() {
12
12
  process.stderr.write(`[gangtise] warning: GANGTISE_TIMEOUT_MS=${rawTimeout} is not in effect, using ${timeoutMs} ms: expected whole milliseconds from ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}\n`);
13
13
  }
14
14
  return {
15
- baseUrl: process.env.GANGTISE_BASE_URL ?? DEFAULT_BASE_URL,
15
+ baseUrl: envValue("GANGTISE_BASE_URL") ?? DEFAULT_BASE_URL,
16
16
  timeoutMs,
17
- accessKey: process.env.GANGTISE_ACCESS_KEY,
18
- secretKey: process.env.GANGTISE_SECRET_KEY,
19
- token: process.env.GANGTISE_TOKEN,
20
- tokenCachePath: process.env.GANGTISE_TOKEN_CACHE_PATH ?? DEFAULT_TOKEN_CACHE_PATH,
17
+ accessKey: envValue("GANGTISE_ACCESS_KEY"),
18
+ secretKey: envValue("GANGTISE_SECRET_KEY"),
19
+ token: envValue("GANGTISE_TOKEN"),
20
+ tokenCachePath: envValue("GANGTISE_TOKEN_CACHE_PATH") ?? DEFAULT_TOKEN_CACHE_PATH,
21
21
  };
22
22
  }
23
+ function envValue(name) {
24
+ const value = process.env[name]?.trim();
25
+ return value ? value : undefined;
26
+ }
23
27
  export const MIN_TIMEOUT_MS = 1_000;
24
28
  export const MAX_TIMEOUT_MS = 3_600_000;
25
29
  export function resolveTimeoutEnv(raw) {
@@ -84,10 +84,27 @@ export function extFromContentType(contentType) {
84
84
  const mime = contentType.split(";")[0].trim().toLowerCase();
85
85
  return MIME_EXT[mime] ?? "";
86
86
  }
87
+ const KNOWN_EXT = new Set(Object.values(MIME_EXT).concat([".md", ".jpeg", ".htm"]));
88
+ export function extFromUrl(url) {
89
+ try {
90
+ const ext = extname(decodeURIComponent(new URL(url).pathname)).toLowerCase();
91
+ return KNOWN_EXT.has(ext) ? ext : "";
92
+ }
93
+ catch {
94
+ return "";
95
+ }
96
+ }
97
+ function resultExt(file) {
98
+ if (file.filename)
99
+ return extname(file.filename);
100
+ if (typeof file.url === "string")
101
+ return extFromUrl(file.url);
102
+ return extFromContentType(file.contentType);
103
+ }
87
104
  export async function resolveTitle(client, result, listEndpoint, idField, idValue, options = {}) {
88
105
  const titleField = options.titleField ?? "title";
89
106
  const file = result;
90
- const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
107
+ const serverExt = resultExt(file);
91
108
  function buildFilename(rawTitle) {
92
109
  let title = sanitizeFilename(rawTitle).trim();
93
110
  if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
@@ -130,7 +147,7 @@ async function downloadUrlTo(url, outputPath) {
130
147
  const { pipeline } = await import("node:stream/promises");
131
148
  const { dirname } = await import("node:path");
132
149
  const { request } = await import("undici");
133
- const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
150
+ const { getDispatcher, logTiming, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
134
151
  const { loadConfig } = await import("./config.js");
135
152
  const timeoutMs = loadConfig().timeoutMs;
136
153
  await fs.mkdir(dirname(outputPath), { recursive: true });
@@ -150,7 +167,7 @@ async function downloadUrlTo(url, outputPath) {
150
167
  method: "GET",
151
168
  headersTimeout: timeoutMs,
152
169
  bodyTimeout: timeoutMs,
153
- signal: AbortSignal.timeout(timeoutMs * 10),
170
+ signal: AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR),
154
171
  dispatcher: getDispatcher(),
155
172
  };
156
173
  let currentUrl = url;
@@ -189,6 +206,23 @@ async function downloadUrlTo(url, outputPath) {
189
206
  throw error;
190
207
  }
191
208
  }
209
+ export function autoDownloadName(file, fallbackName) {
210
+ return truncateFilename(file.filename ? sanitizeFilename(file.filename) : sanitizeFilename(fallbackName) + extFromContentType(file.contentType));
211
+ }
212
+ export async function placeStreamedDownload(file, fallbackName, named) {
213
+ let target;
214
+ try {
215
+ target = await uniquePath(truncateFilename(named ?? autoDownloadName(file, fallbackName)));
216
+ await fs.rename(file.savedPath, target);
217
+ }
218
+ catch (error) {
219
+ if (target)
220
+ await releaseClaim(target);
221
+ await fs.unlink(file.savedPath).catch(() => { });
222
+ throw error;
223
+ }
224
+ return target;
225
+ }
192
226
  export async function saveDownloadResult(result, fallbackName, output) {
193
227
  if (!(result && typeof result === "object")) {
194
228
  throw new DownloadError("Unexpected download response");
@@ -200,8 +234,7 @@ export async function saveDownloadResult(result, fallbackName, output) {
200
234
  return;
201
235
  }
202
236
  if (file.data instanceof Uint8Array) {
203
- const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
204
- const outputPath = output ?? await uniquePath(truncateFilename(autoName));
237
+ const outputPath = output ?? await uniquePath(autoDownloadName(file, fallbackName));
205
238
  try {
206
239
  await saveOutputIfNeeded(file.data, outputPath, false);
207
240
  }
@@ -227,12 +260,16 @@ export async function saveDownloadResult(result, fallbackName, output) {
227
260
  return;
228
261
  }
229
262
  if (typeof file.url === "string") {
230
- if (output) {
231
- await downloadUrlTo(file.url, output);
232
- process.stdout.write(`${output}\n`);
233
- return;
263
+ const outputPath = output ?? await uniquePath(truncateFilename(safeFallback + extFromUrl(file.url)));
264
+ try {
265
+ await downloadUrlTo(file.url, outputPath);
266
+ }
267
+ catch (error) {
268
+ if (!output)
269
+ await releaseClaim(outputPath);
270
+ throw error;
234
271
  }
235
- process.stdout.write(`${file.url}\n`);
272
+ process.stdout.write(`${outputPath}\n`);
236
273
  return;
237
274
  }
238
275
  throw new DownloadError("Unexpected download response");
@@ -1,3 +1,31 @@
1
+ import { beijingDateString, parseDateOption } from "./args.js";
2
+ import { ValidationError } from "./errors.js";
3
+ export const COSTLY_FETCH_CREDITS = 1000;
4
+ const FORECAST_ROWS_PER_DATE = 3;
5
+ function billedDate(value, key) {
6
+ if (typeof value !== "string")
7
+ throw new ValidationError(`${key} is not a date string`);
8
+ return parseDateOption(value, key);
9
+ }
10
+ export function weekdaysBetween(start, end) {
11
+ const from = Date.parse(`${start}T00:00:00Z`);
12
+ const to = Date.parse(`${end}T00:00:00Z`);
13
+ if (!(to >= from))
14
+ return 0;
15
+ let count = 0;
16
+ for (let t = from; t <= to; t += 86_400_000) {
17
+ const day = new Date(t).getUTCDay();
18
+ if (day !== 0 && day !== 6)
19
+ count++;
20
+ }
21
+ return count;
22
+ }
23
+ export function maxUnitsOf(key) {
24
+ const max = ENDPOINTS[key]?.billing?.maxUnits;
25
+ if (max === undefined)
26
+ throw new Error(`${key} declares no billing.maxUnits`);
27
+ return max;
28
+ }
1
29
  export function resolveTimeoutMs(configTimeoutMs, endpoint) {
2
30
  return Math.max(configTimeoutMs, endpoint.timeoutMs ?? 0);
3
31
  }
@@ -502,6 +530,11 @@ const ENDPOINT_DEFS = {
502
530
  kind: "json",
503
531
  description: "Query earning forecast (consensus estimates)",
504
532
  billing: { per: "row", price: 0.5, maxUnits: Number.POSITIVE_INFINITY },
533
+ estimateBilledUnits: (body) => {
534
+ const end = body.endDate == null ? beijingDateString(new Date()) : billedDate(body.endDate, "endDate");
535
+ const start = body.startDate == null ? new Date(Date.parse(`${end}T00:00:00Z`) - 365 * 86_400_000).toISOString().slice(0, 10) : billedDate(body.startDate, "startDate");
536
+ return weekdaysBetween(start, end) * FORECAST_ROWS_PER_DATE;
537
+ },
505
538
  retry: "no-replay",
506
539
  },
507
540
  "bond.basic-info": {
@@ -612,6 +645,168 @@ const ENDPOINT_DEFS = {
612
645
  retry: "no-replay",
613
646
  expects: "list",
614
647
  },
648
+ "fund.basic-info": {
649
+ method: "POST",
650
+ path: "/application/open-fundamental/fund/basic-info",
651
+ kind: "json",
652
+ description: "Query fund profiles (category, manager, subscription rules, benchmark, tracked index)",
653
+ billing: { per: "call", price: 0.4 },
654
+ retry: "no-replay",
655
+ expects: "list",
656
+ },
657
+ "fund.nav": {
658
+ method: "POST",
659
+ path: "/application/open-quote/fund/nav",
660
+ kind: "json",
661
+ description: "Query fund daily NAV (unit / accumulated / adjusted; money-market yields)",
662
+ billing: { per: "call", price: 0.4 },
663
+ retry: "no-replay",
664
+ expects: "list",
665
+ },
666
+ "fund.fee-rate": {
667
+ method: "POST",
668
+ path: "/application/open-fundamental/fund/fee-rate",
669
+ kind: "json",
670
+ description: "Query fund fee rates (purchase / redemption / management / custodian / sales service)",
671
+ billing: { per: "call", price: 0.4 },
672
+ retry: "no-replay",
673
+ expects: "list",
674
+ },
675
+ "fund.manager-info": {
676
+ method: "POST",
677
+ path: "/application/open-fundamental/fund/manager-info",
678
+ kind: "json",
679
+ description: "Query fund manager profiles by name (exact match; same-name managers all returned)",
680
+ billing: { per: "call", price: 0.4 },
681
+ retry: "no-replay",
682
+ expects: "list",
683
+ },
684
+ "fund.manager-history": {
685
+ method: "POST",
686
+ path: "/application/open-fundamental/fund/manager-history",
687
+ kind: "json",
688
+ description: "Query a fund's past and present managers (tenure + return over tenure)",
689
+ billing: { per: "call", price: 0.4 },
690
+ retry: "no-replay",
691
+ expects: "list",
692
+ },
693
+ "fund.asset-allocation": {
694
+ method: "POST",
695
+ path: "/application/open-fundamental/fund/asset-allocation",
696
+ kind: "json",
697
+ description: "Query a fund's asset allocation per report date (equity / bonds / cash ...)",
698
+ billing: { per: "call", price: 0.4 },
699
+ retry: "no-replay",
700
+ expects: "list",
701
+ },
702
+ "fund.asset-size": {
703
+ method: "POST",
704
+ path: "/application/open-fundamental/fund/asset-size",
705
+ kind: "json",
706
+ description: "Query a fund's shares, subscriptions / redemptions and net assets per report date",
707
+ billing: { per: "call", price: 0.4 },
708
+ retry: "no-replay",
709
+ expects: "list",
710
+ },
711
+ "fund.holder-structure": {
712
+ method: "POST",
713
+ path: "/application/open-fundamental/fund/holder-structure",
714
+ kind: "json",
715
+ description: "Query a fund's holder structure per report date (institutional / individual / employee)",
716
+ billing: { per: "call", price: 0.4 },
717
+ retry: "no-replay",
718
+ expects: "list",
719
+ },
720
+ "fund.top10-holders": {
721
+ method: "POST",
722
+ path: "/application/open-fundamental/fund/top10-holders",
723
+ kind: "json",
724
+ description: "Query a listed fund's top 10 holders per report date",
725
+ billing: { per: "call", price: 0.4 },
726
+ retry: "no-replay",
727
+ expects: "list",
728
+ },
729
+ "fund.stock-portfolio": {
730
+ method: "POST",
731
+ path: "/application/open-fundamental/fund/stock-portfolio",
732
+ kind: "json",
733
+ description: "Query a fund's stock holdings per report date",
734
+ billing: { per: "call", price: 0.4 },
735
+ retry: "no-replay",
736
+ expects: "list",
737
+ },
738
+ "fund.industry-allocation": {
739
+ method: "POST",
740
+ path: "/application/open-fundamental/fund/industry-allocation",
741
+ kind: "json",
742
+ description: "Query a fund's stock holdings grouped by industry (SW / CITIC level 1)",
743
+ billing: { per: "call", price: 0.4 },
744
+ retry: "no-replay",
745
+ expects: "list",
746
+ },
747
+ "fund.bond-portfolio": {
748
+ method: "POST",
749
+ path: "/application/open-fundamental/fund/bond-portfolio",
750
+ kind: "json",
751
+ description: "Query a fund's bond holdings per report date",
752
+ billing: { per: "call", price: 0.4 },
753
+ retry: "no-replay",
754
+ expects: "list",
755
+ },
756
+ "fund.bond-type-allocation": {
757
+ method: "POST",
758
+ path: "/application/open-fundamental/fund/bond-type-allocation",
759
+ kind: "json",
760
+ description: "Query a fund's bond holdings grouped by bond type",
761
+ billing: { per: "call", price: 0.4 },
762
+ retry: "no-replay",
763
+ expects: "list",
764
+ },
765
+ "fund.fund-portfolio": {
766
+ method: "POST",
767
+ path: "/application/open-fundamental/fund/fund-portfolio",
768
+ kind: "json",
769
+ description: "Query a fund-of-funds' holdings of other funds",
770
+ billing: { per: "call", price: 0.4 },
771
+ retry: "no-replay",
772
+ expects: "list",
773
+ },
774
+ "fund.fund-type-allocation": {
775
+ method: "POST",
776
+ path: "/application/open-fundamental/fund/fund-type-allocation",
777
+ kind: "json",
778
+ description: "Query a fund-of-funds' holdings grouped by fund category",
779
+ billing: { per: "call", price: 0.4 },
780
+ retry: "no-replay",
781
+ expects: "list",
782
+ },
783
+ "fund.etf-pcf-header": {
784
+ method: "POST",
785
+ path: "/application/open-fundamental/fund/etf-pcf-header",
786
+ kind: "json",
787
+ description: "Query ETF creation/redemption parameters (latest trade date)",
788
+ billing: { per: "call", price: 0.4 },
789
+ retry: "no-replay",
790
+ expects: "list",
791
+ },
792
+ "fund.etf-pcf-components": {
793
+ method: "POST",
794
+ path: "/application/open-fundamental/fund/etf-pcf-components",
795
+ kind: "json",
796
+ description: "Query the ETF creation/redemption component list (latest trade date)",
797
+ billing: { per: "call", price: 0.4 },
798
+ retry: "no-replay",
799
+ expects: "list",
800
+ },
801
+ "fund.etf-share-change": {
802
+ method: "POST",
803
+ path: "/application/open-fundamental/fund/etf-share-change",
804
+ kind: "json",
805
+ description: "Query ETF shares and scale per trade date",
806
+ billing: { per: "call", price: 0.4 },
807
+ retry: "no-replay",
808
+ expects: "list",
809
+ },
615
810
  "ai.stock-summary.list": {
616
811
  method: "POST",
617
812
  path: "/application/open-ai/stock-summary/getList",
@@ -48,7 +48,7 @@ const ERROR_HINTS = {
48
48
  "100003": "msg 已指明字段名或取值范围时直接按 msg 改;msg 只说「参数值非法」时多为枚举参数拼写错误(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
49
49
  "100004": "检查 --size / --from 是否为非负数且未超单页上限。",
50
50
  "100005": "对照命令 --help 列出的合法取值检查。",
51
- "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑。",
51
+ "100006": "缩短日期范围或调小 --size / --limit(list 类端点单页上限为 50,CLI 自动翻页,直发接口时要自己遵守);indicator cross-section / time-series 撞此码是单次单元格数超 30000(截面=证券数×指标数,时序=证券数或指标数×日期数),按这个乘积拆批重跑;fund 命令不分页,撞此码是单次超过 10000 行,减少 --security 只数或缩短日期区间分批查。",
52
52
  "110001": "看参数名:`--*-date` 用 YYYY-MM-DD,`--*-time` 用 \"YYYY-MM-DD HH:mm:ss\"(`ai knowledge-batch` 的 --start-time/--end-time 收时间戳或 datetime,CLI 统一转 13 位毫秒)。",
53
53
  "110002": "起始晚于结束——检查 --start-date/--end-date 或 --start-time/--end-time 的先后。",
54
54
  "110003": "查询时间超出本账号的数据权限范围——把日期移进范围内(整个区间都早于下界时缩短窗口无用),或联系客户经理开通更长历史。",