gangtise-openapi-cli 0.51.0 → 0.52.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 (41) hide show
  1. package/README.md +77 -49
  2. package/dist/src/cli.js +29 -8
  3. package/dist/src/commands/ai.js +12 -12
  4. package/dist/src/commands/alternative.js +2 -2
  5. package/dist/src/commands/bond.js +4 -4
  6. package/dist/src/commands/fund.js +1 -1
  7. package/dist/src/commands/fundamental.js +11 -11
  8. package/dist/src/commands/indicator.js +5 -5
  9. package/dist/src/commands/insight.js +38 -17
  10. package/dist/src/commands/quote.js +6 -6
  11. package/dist/src/commands/raw.js +3 -3
  12. package/dist/src/commands/reference.js +12 -3
  13. package/dist/src/commands/shared.js +20 -8
  14. package/dist/src/commands/tool.js +9 -4
  15. package/dist/src/commands/update.js +11 -0
  16. package/dist/src/commands/vault.js +15 -15
  17. package/dist/src/core/client.js +18 -2
  18. package/dist/src/core/download.js +12 -3
  19. package/dist/src/core/driveUpload.js +6 -2
  20. package/dist/src/core/dryRun.js +68 -0
  21. package/dist/src/core/endpoints.js +61 -6
  22. package/dist/src/core/etfComponents.js +45 -8
  23. package/dist/src/core/exitStatus.js +4 -0
  24. package/dist/src/core/fileParse.js +10 -3
  25. package/dist/src/core/printer.js +39 -7
  26. package/dist/src/core/statePaths.js +41 -0
  27. package/dist/src/core/transport.js +7 -0
  28. package/gangtise-openapi/SKILL.md +34 -154
  29. package/gangtise-openapi/references/billing.md +142 -0
  30. package/gangtise-openapi/references/commands/fund.md +3 -3
  31. package/gangtise-openapi/references/commands/fundamental.md +1 -0
  32. package/gangtise-openapi/references/commands/indicator.md +51 -8
  33. package/gangtise-openapi/references/commands/insight.md +31 -6
  34. package/gangtise-openapi/references/commands/reference-and-lookup.md +22 -4
  35. package/gangtise-openapi/references/commands/tool.md +2 -2
  36. package/gangtise-openapi/references/commands/vault.md +1 -1
  37. package/gangtise-openapi/references/errors.md +2 -2
  38. package/gangtise-openapi/references/examples.md +3 -3
  39. package/gangtise-openapi/references/lookup-ids.md +13 -0
  40. package/gangtise-openapi/references/response-schema.md +4 -1
  41. package/package.json +2 -2
@@ -1,6 +1,13 @@
1
1
  import { beijingDateString } from "./args.js";
2
2
  import { normalizeRows } from "./normalize.js";
3
3
  import { PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
4
+ function weekdayBefore(day) {
5
+ const d = new Date(`${day}T00:00:00Z`);
6
+ do
7
+ d.setUTCDate(d.getUTCDate() - 1);
8
+ while (d.getUTCDay() === 0 || d.getUTCDay() === 6);
9
+ return d.toISOString().slice(0, 10);
10
+ }
4
11
  const PRICE_TOLERANCE = 0.0005;
5
12
  const agrees = (close, price) => price > 0 && Math.abs(close - price) / price <= PRICE_TOLERANCE;
6
13
  const CHANGE_TOLERANCE = 0.005;
@@ -138,7 +145,8 @@ export async function fillComponentCodes(rows, lookup) {
138
145
  }
139
146
  });
140
147
  const quotable = [...new Set([...candidates.values()].flat().filter((c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code)).map((c) => c.code))];
141
- const prices = new Map((await runWithConcurrency(inBatches(quotable, QUOTE_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.quotes(codes)))).flatMap((quoted) => [...quoted]));
148
+ const quotes = new Map((await runWithConcurrency(inBatches(quotable, QUOTE_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.quotes(codes)))).flatMap((quoted) => [...quoted]));
149
+ const prices = new Map([...quotes].map(([code, q]) => [code, [q.previous, q.latest].filter((p) => typeof p === "number")]));
142
150
  const isQuotable = (c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code);
143
151
  const candidatesOf = (row) => candidates.get(row.componentName) ?? [];
144
152
  const marketsOf = (listed) => new Set(listed.map((c) => market(c.code)));
@@ -185,7 +193,30 @@ export async function fillComponentCodes(rows, lookup) {
185
193
  history.set(code, series);
186
194
  }
187
195
  }
188
- const mayBe = (row, c) => !history.has(c.code) || history.get(c.code).some(([, close]) => agrees(row.close, close));
196
+ const unshown = (close, c) => {
197
+ const { previous, latest, date } = quotes.get(c.code) ?? {};
198
+ const onLatest = latest !== undefined && agrees(close, latest);
199
+ const onPrevious = previous !== undefined && agrees(close, previous);
200
+ if (!onLatest && !onPrevious)
201
+ return false;
202
+ if (date === undefined)
203
+ return true;
204
+ const days = (history.get(c.code) ?? []).map(([day]) => day);
205
+ const reachesQuote = days.some((day) => day >= date);
206
+ const reachesPrevious = reachesQuote || days.includes(weekdayBefore(date));
207
+ return (onLatest && !reachesQuote) || (onPrevious && !reachesPrevious);
208
+ };
209
+ const marketDays = new Map([...etfs].map(([fundCode, { own }]) => [fundCode, listDates(own, candidates, history)]));
210
+ const startsLate = (row, c) => {
211
+ const first = history.get(c.code)?.[0]?.[0];
212
+ if (first === undefined)
213
+ return true;
214
+ const day = marketDays.get(row.fundCode)?.get(market(c.code));
215
+ if (day !== undefined)
216
+ return first > day;
217
+ return candidatesOf(row).some((o) => o.code !== c.code && (history.get(o.code) ?? []).some(([day, price]) => day < first && agrees(row.close, price)));
218
+ };
219
+ const mayBe = (row, c) => !history.has(c.code) || history.get(c.code).some(([, close]) => agrees(row.close, close)) || unshown(row.close, c) || startsLate(row, c);
189
220
  for (const { own, etf } of etfs.values()) {
190
221
  for (const row of own) {
191
222
  const matched = unverified.get(row);
@@ -221,9 +252,14 @@ export async function fillComponentCodes(rows, lookup) {
221
252
  if (!pool.length || !pool.every((c) => isQuotable(c) && history.has(c.code)))
222
253
  continue;
223
254
  const atClose = (c) => history.get(c.code).filter(([, price]) => agrees(close, price));
224
- const hit = pool.filter((c) => atClose(c).some(([, , moved]) => moved !== undefined && Math.abs(moved - change) <= CHANGE_TOLERANCE));
225
- const unknown = pool.some((c) => !hit.includes(c) && atClose(c).some(([, , moved]) => moved === undefined));
226
- if (!hit.length || unknown || marketsOf(hit).size > 1)
255
+ const fits = (c) => atClose(c).filter(([, , moved]) => moved !== undefined && Math.abs(moved - change) <= CHANGE_TOLERANCE);
256
+ const hit = pool.filter((c) => fits(c).length);
257
+ if (!hit.length)
258
+ continue;
259
+ const unknown = pool.some((c) => !hit.includes(c) && (atClose(c).some(([, , moved]) => moved === undefined)
260
+ || unshown(close, c)
261
+ || startsLate(row, c)));
262
+ if (unknown || marketsOf(hit).size > 1)
227
263
  continue;
228
264
  const code = narrow(hit, etf);
229
265
  if (code)
@@ -367,10 +403,11 @@ export function clientLookup(client) {
367
403
  const data = normalizeRows(await client.call("quote.realtime", { securityList: codes }));
368
404
  const list = Array.isArray(data) ? data : (data?.list ?? []);
369
405
  const out = new Map();
406
+ const price = (value) => (typeof value === "number" ? value : undefined);
370
407
  for (const q of list) {
371
- const prices = [q.preClose, q.latestPrice].filter((p) => typeof p === "number");
372
- if (typeof q.securityCode === "string" && prices.length)
373
- out.set(q.securityCode, prices);
408
+ const quote = { previous: price(q.preClose), latest: price(q.latestPrice), date: typeof q.tradeDate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(q.tradeDate) ? q.tradeDate : undefined };
409
+ if (typeof q.securityCode === "string" && (quote.previous !== undefined || quote.latest !== undefined))
410
+ out.set(q.securityCode, quote);
374
411
  }
375
412
  return out;
376
413
  },
@@ -1,9 +1,13 @@
1
1
  export const EXIT_FAILED = 1;
2
+ export const EXIT_REFUSED = 2;
2
3
  export const EXIT_INCOMPLETE = 3;
3
4
  export const EXIT_SUPERSEDED = 4;
4
5
  export function markFailed() {
5
6
  process.exitCode = EXIT_FAILED;
6
7
  }
8
+ export function markRefused() {
9
+ process.exitCode = EXIT_REFUSED;
10
+ }
7
11
  export function markIncomplete() {
8
12
  if (process.exitCode !== EXIT_FAILED)
9
13
  process.exitCode = EXIT_INCOMPLETE;
@@ -3,11 +3,14 @@ import path from "node:path";
3
3
  import { isAsyncPending, pollUntilDone } from "./asyncContent.js";
4
4
  import { saveDownloadResult } from "./download.js";
5
5
  import { ApiError, ValidationError } from "./errors.js";
6
+ import { assertNotStatePath } from "./statePaths.js";
6
7
  export const FILE_PARSE_MAX_BYTES = 100 * 1024 * 1024;
7
8
  export async function submitFileParse(client, filePath) {
8
- const resolved = path.resolve(filePath);
9
+ const shownAs = path.resolve(filePath);
10
+ let resolved;
9
11
  let stat;
10
12
  try {
13
+ resolved = await fs.realpath(filePath);
11
14
  stat = await fs.stat(resolved);
12
15
  }
13
16
  catch {
@@ -15,17 +18,21 @@ export async function submitFileParse(client, filePath) {
15
18
  }
16
19
  if (!stat.isFile())
17
20
  throw new ValidationError(`Not a file: ${filePath}`);
21
+ await assertNotStatePath(resolved, "--file");
18
22
  if (stat.size === 0)
19
23
  throw new ValidationError(`File is empty: ${filePath}`);
20
24
  if (stat.size > FILE_PARSE_MAX_BYTES) {
21
25
  throw new ValidationError(`File is ${(stat.size / 1024 / 1024).toFixed(1)}MB — the parse API accepts at most 100MB`);
22
26
  }
23
- if (path.extname(resolved).toLowerCase() !== ".pdf") {
27
+ if (path.extname(shownAs).toLowerCase() !== ".pdf") {
24
28
  throw new ValidationError(`Only PDF files are supported: ${filePath}`);
25
29
  }
26
30
  const data = await fs.readFile(resolved);
31
+ if (!data.subarray(0, 1024).toString("latin1").includes("%PDF-")) {
32
+ throw new ValidationError(`Not a PDF (no %PDF- header in its first 1KB): ${filePath}`);
33
+ }
27
34
  const result = await client.uploadFile("tool.file-parse.submit", {
28
- filename: path.basename(resolved),
35
+ filename: path.basename(shownAs),
29
36
  data,
30
37
  contentType: "application/pdf",
31
38
  });
@@ -6,6 +6,7 @@ import { getRowSink } from "./rowSink.js";
6
6
  import { extractTitles, writeTitleCache } from "./titleCache.js";
7
7
  import { CLI_VERSION } from "../version.js";
8
8
  import { decidedExitCode, EXIT_SUPERSEDED, markIncomplete, markSuperseded } from "./exitStatus.js";
9
+ import { describeNoRequest, isDryRun } from "./dryRun.js";
9
10
  const LARGE_RESULT_ROWS = 50_000;
10
11
  export const SUPERSEDED_EXIT = EXIT_SUPERSEDED;
11
12
  export async function warnIfSuperseded(output, digest) {
@@ -101,19 +102,50 @@ async function stageExportMeta(output, format, rows, columns, normalized, comple
101
102
  },
102
103
  };
103
104
  }
105
+ let selectedColumns;
106
+ export function setColumns(names) {
107
+ selectedColumns = names.length > 0 ? names : undefined;
108
+ }
109
+ export function columnsSelected() {
110
+ return selectedColumns !== undefined;
111
+ }
112
+ function projectColumns(value) {
113
+ const names = selectedColumns;
114
+ if (!names)
115
+ return value;
116
+ const isRecord = (row) => Boolean(row) && typeof row === "object" && !Array.isArray(row);
117
+ const rows = pickList(value) ?? (isRecord(value) ? [value] : []);
118
+ const present = new Set(rows.filter(isRecord).flatMap((row) => Object.keys(row)));
119
+ const absent = names.filter((name) => !present.has(name));
120
+ if (absent.length > 0 && present.size > 0) {
121
+ process.stderr.write(`[gangtise] warning: --columns names ${absent.join(", ")}, which no row has; the rows have ${[...present].join(", ")}.\n`);
122
+ }
123
+ const pick = (row) => (isRecord(row) ? Object.fromEntries(names.map((name) => [name, row[name] ?? null])) : row);
124
+ if (Array.isArray(value))
125
+ return value.map(pick);
126
+ if (!isRecord(value))
127
+ return value;
128
+ return Array.isArray(value.list) ? { ...value, list: value.list.map(pick) } : pick(value);
129
+ }
104
130
  export async function printData(data, format, output, cache) {
131
+ if (isDryRun()) {
132
+ process.stdout.write(`${JSON.stringify(describeNoRequest(output), null, 2)}\n`);
133
+ return;
134
+ }
105
135
  const sink = getRowSink(data);
106
136
  if (sink && !sink.opened)
107
137
  data.list = sink.takeBuffer();
108
138
  const streamed = sink?.opened ? sink : undefined;
109
- const columns = data && typeof data === "object" && Array.isArray(data.fieldList)
139
+ const columns = selectedColumns ?? (data && typeof data === "object" && Array.isArray(data.fieldList)
110
140
  ? data.fieldList
111
- : undefined;
112
- const normalized = normalizeRows(data);
141
+ : undefined);
142
+ const whole = normalizeRows(data);
143
+ const wholeItems = pickList(whole);
144
+ const normalized = projectColumns(whole);
113
145
  const items = pickList(normalized);
114
146
  const showing = streamed ? streamed.rows : (items?.length ?? 0);
115
147
  if (cache) {
116
- const titles = { ...(streamed?.titles ?? {}), ...(items ? extractTitles(items, cache) : {}) };
148
+ const titles = { ...(streamed?.titles ?? {}), ...(wholeItems ? extractTitles(wholeItems, cache) : {}) };
117
149
  if (Object.keys(titles).length > 0) {
118
150
  writeTitleCache(cache.endpointKey, titles).catch(() => { });
119
151
  }
@@ -123,13 +155,13 @@ export async function printData(data, format, output, cache) {
123
155
  if (typeof meta.total === "number" && format !== "json") {
124
156
  process.stderr.write(`Total: ${meta.total}, showing: ${showing}\n`);
125
157
  }
126
- if (meta.partial === true)
127
- markIncomplete();
128
158
  }
159
+ if (whole && typeof whole === "object" && !Array.isArray(whole) && whole.partial === true)
160
+ markIncomplete();
129
161
  const complete = decidedExitCode() === 0;
130
162
  if (output) {
131
163
  const staged = format === "csv" || format === "jsonl"
132
- ? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, normalized, complete)
164
+ ? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, whole, complete)
133
165
  : null;
134
166
  let digest;
135
167
  try {
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DEFAULT_TOKEN_CACHE_PATH, loadConfig } from "./config.js";
4
+ import { ValidationError } from "./errors.js";
5
+ const identity = async (target) => {
6
+ const stat = await fs.stat(target).catch(() => undefined);
7
+ return stat ? `${stat.dev}:${stat.ino}` : undefined;
8
+ };
9
+ async function physical(target) {
10
+ const rest = [];
11
+ let head = path.isAbsolute(target) ? target : `${process.cwd()}${path.sep}${target}`;
12
+ for (;;) {
13
+ const real = await fs.realpath(head).catch(() => undefined);
14
+ if (real)
15
+ return path.join(real, ...rest);
16
+ const parent = path.dirname(head);
17
+ if (parent === head)
18
+ return path.resolve(target);
19
+ rest.unshift(path.basename(head));
20
+ head = parent;
21
+ }
22
+ }
23
+ export async function assertNotStatePath(target, option) {
24
+ const stateDir = path.dirname(DEFAULT_TOKEN_CACHE_PATH);
25
+ const tokenCache = loadConfig().tokenCachePath;
26
+ const guarded = new Set((await Promise.all([stateDir, tokenCache].map(identity))).filter((id) => id !== undefined));
27
+ const resolved = await physical(target);
28
+ let refused = resolved === await physical(tokenCache);
29
+ for (let current = resolved; !refused;) {
30
+ const id = await identity(current);
31
+ if (id && guarded.has(id))
32
+ refused = true;
33
+ const parent = path.dirname(current);
34
+ if (parent === current)
35
+ break;
36
+ current = parent;
37
+ }
38
+ if (refused) {
39
+ throw new ValidationError(`${option} ${target} points at gangtise's own state (${stateDir}, or the token cache ${tokenCache}), which holds the access token: refused. Pass a path outside it.`);
40
+ }
41
+ }
@@ -34,6 +34,13 @@ function retryAfterFromError(error) {
34
34
  return undefined;
35
35
  }
36
36
  let cachedDispatcher = null;
37
+ let requestSent = false;
38
+ export function noteRequestSent() {
39
+ requestSent = true;
40
+ }
41
+ export function anyRequestSent() {
42
+ return requestSent;
43
+ }
37
44
  const requireLazily = createRequire(import.meta.url);
38
45
  export function getDispatcher() {
39
46
  if (!cachedDispatcher) {