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.
- package/README.md +77 -49
- package/dist/src/cli.js +29 -8
- package/dist/src/commands/ai.js +12 -12
- package/dist/src/commands/alternative.js +2 -2
- package/dist/src/commands/bond.js +4 -4
- package/dist/src/commands/fund.js +1 -1
- package/dist/src/commands/fundamental.js +11 -11
- package/dist/src/commands/indicator.js +5 -5
- package/dist/src/commands/insight.js +38 -17
- package/dist/src/commands/quote.js +6 -6
- package/dist/src/commands/raw.js +3 -3
- package/dist/src/commands/reference.js +12 -3
- package/dist/src/commands/shared.js +20 -8
- package/dist/src/commands/tool.js +9 -4
- package/dist/src/commands/update.js +11 -0
- package/dist/src/commands/vault.js +15 -15
- package/dist/src/core/client.js +18 -2
- package/dist/src/core/download.js +12 -3
- package/dist/src/core/driveUpload.js +6 -2
- package/dist/src/core/dryRun.js +68 -0
- package/dist/src/core/endpoints.js +61 -6
- package/dist/src/core/etfComponents.js +45 -8
- package/dist/src/core/exitStatus.js +4 -0
- package/dist/src/core/fileParse.js +10 -3
- package/dist/src/core/printer.js +39 -7
- package/dist/src/core/statePaths.js +41 -0
- package/dist/src/core/transport.js +7 -0
- package/gangtise-openapi/SKILL.md +34 -154
- package/gangtise-openapi/references/billing.md +142 -0
- package/gangtise-openapi/references/commands/fund.md +3 -3
- package/gangtise-openapi/references/commands/fundamental.md +1 -0
- package/gangtise-openapi/references/commands/indicator.md +51 -8
- package/gangtise-openapi/references/commands/insight.md +31 -6
- package/gangtise-openapi/references/commands/reference-and-lookup.md +22 -4
- package/gangtise-openapi/references/commands/tool.md +2 -2
- package/gangtise-openapi/references/commands/vault.md +1 -1
- package/gangtise-openapi/references/errors.md +2 -2
- package/gangtise-openapi/references/examples.md +3 -3
- package/gangtise-openapi/references/lookup-ids.md +13 -0
- package/gangtise-openapi/references/response-schema.md +4 -1
- 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
|
|
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
|
|
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
|
|
225
|
-
const
|
|
226
|
-
if (!hit.length
|
|
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
|
|
372
|
-
if (typeof q.securityCode === "string" &&
|
|
373
|
-
out.set(q.securityCode,
|
|
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
|
|
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(
|
|
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(
|
|
35
|
+
filename: path.basename(shownAs),
|
|
29
36
|
data,
|
|
30
37
|
contentType: "application/pdf",
|
|
31
38
|
});
|
package/dist/src/core/printer.js
CHANGED
|
@@ -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
|
|
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 ?? {}), ...(
|
|
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,
|
|
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) {
|