gangtise-openapi-cli 0.50.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 (43) hide show
  1. package/README.md +94 -49
  2. package/dist/src/cli.js +29 -8
  3. package/dist/src/commands/ai.js +27 -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 +35 -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 +17 -16
  17. package/dist/src/core/client.js +21 -4
  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 +87 -6
  22. package/dist/src/core/errors.js +2 -2
  23. package/dist/src/core/etfComponents.js +65 -8
  24. package/dist/src/core/exitStatus.js +4 -0
  25. package/dist/src/core/fileParse.js +10 -3
  26. package/dist/src/core/printer.js +39 -7
  27. package/dist/src/core/statePaths.js +41 -0
  28. package/dist/src/core/transport.js +7 -0
  29. package/gangtise-openapi/SKILL.md +43 -156
  30. package/gangtise-openapi/references/billing.md +142 -0
  31. package/gangtise-openapi/references/commands/ai.md +26 -0
  32. package/gangtise-openapi/references/commands/fund.md +3 -3
  33. package/gangtise-openapi/references/commands/fundamental.md +2 -1
  34. package/gangtise-openapi/references/commands/indicator.md +106 -15
  35. package/gangtise-openapi/references/commands/insight.md +31 -6
  36. package/gangtise-openapi/references/commands/reference-and-lookup.md +42 -4
  37. package/gangtise-openapi/references/commands/tool.md +2 -2
  38. package/gangtise-openapi/references/commands/vault.md +26 -4
  39. package/gangtise-openapi/references/errors.md +3 -3
  40. package/gangtise-openapi/references/examples.md +3 -3
  41. package/gangtise-openapi/references/lookup-ids.md +13 -0
  42. package/gangtise-openapi/references/response-schema.md +8 -2
  43. package/package.json +2 -2
@@ -71,7 +71,7 @@ const ENDPOINT_DEFS = {
71
71
  path: "/application/open-insight/chief-opinion/getDetail",
72
72
  kind: "json",
73
73
  description: "Get domestic chief opinion bodies by ID (max 20 IDs per call)",
74
- billing: { per: "row", price: 30, maxUnits: 20 },
74
+ billing: { per: "row", price: 30, maxUnits: 20, unitsFrom: ["chiefOpinionIdList"] },
75
75
  retry: "no-replay",
76
76
  expects: "array",
77
77
  },
@@ -267,7 +267,7 @@ const ENDPOINT_DEFS = {
267
267
  path: "/application/open-insight/foreign-opinion/getDetail",
268
268
  kind: "json",
269
269
  description: "Get foreign opinion bodies by ID (max 20 IDs per call)",
270
- billing: { per: "row", price: 30, maxUnits: 20 },
270
+ billing: { per: "row", price: 30, maxUnits: 20, unitsFrom: ["foreignOpinionIdList"] },
271
271
  retry: "no-replay",
272
272
  expects: "array",
273
273
  },
@@ -288,6 +288,25 @@ const ENDPOINT_DEFS = {
288
288
  description: "Download foreign independent opinion file",
289
289
  billing: { per: "document", price: 30 },
290
290
  },
291
+ "insight.overseas-opinion.list": {
292
+ method: "POST",
293
+ path: "/application/open-insight/overseasOpinion/getList",
294
+ kind: "json",
295
+ description: "List overseas opinions (brief only; body via detail)",
296
+ billing: { per: "row", price: 0.1 },
297
+ pagination: { enabled: true, maxPageSize: 50, maxWindow: 10000 },
298
+ rowId: "overseasOpinionId",
299
+ expects: "list",
300
+ },
301
+ "insight.overseas-opinion.detail": {
302
+ method: "POST",
303
+ path: "/application/open-insight/overseasOpinion/getDetail",
304
+ kind: "json",
305
+ description: "Get overseas opinion bodies by ID (max 20 IDs per call)",
306
+ billing: { per: "row", price: 10, maxUnits: 20, unitsFrom: ["overseasOpinionIdList"] },
307
+ retry: "no-replay",
308
+ expects: "array",
309
+ },
291
310
  "insight.official-account.list": {
292
311
  method: "POST",
293
312
  path: "/application/open-insight/officialAccount/getList",
@@ -317,6 +336,7 @@ const ENDPOINT_DEFS = {
317
336
  path: "/application/open-insight/report-image/getList",
318
337
  kind: "json",
319
338
  description: "Search research report images by keyword (returns chunkId + metadata)",
339
+ expects: "array",
320
340
  },
321
341
  "insight.highlight.list": {
322
342
  method: "POST",
@@ -341,54 +361,79 @@ const ENDPOINT_DEFS = {
341
361
  path: "/application/open-reference/securities/search",
342
362
  kind: "json",
343
363
  description: "Search GTS codes (securities)",
364
+ expects: "list",
344
365
  },
345
366
  "reference.chiefs-search": {
346
367
  method: "POST",
347
368
  path: "/application/open-reference/chiefs/search",
348
369
  kind: "json",
349
370
  description: "Search chief analyst IDs by name / institution / team",
371
+ expects: "list",
350
372
  },
351
373
  "reference.institution-search": {
352
374
  method: "POST",
353
375
  path: "/application/open-reference/institutions/search",
354
376
  kind: "json",
355
377
  description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
378
+ expects: "list",
356
379
  },
357
380
  "reference.official-account-search": {
358
381
  method: "POST",
359
382
  path: "/application/open-reference/officialAccount/search",
360
383
  kind: "json",
361
384
  description: "Search official account (WeChat public account) IDs by name / institution / category",
385
+ expects: "list",
386
+ },
387
+ "reference.overseas-account-search": {
388
+ method: "POST",
389
+ path: "/application/open-reference/overseasAccount/search",
390
+ kind: "json",
391
+ description: "Search overseas opinion account IDs by name fragment",
392
+ pagination: { enabled: true, maxPageSize: 50 },
393
+ rowId: "accountId",
394
+ expects: "list",
362
395
  },
363
396
  "reference.constant-category": {
364
397
  method: "GET",
365
398
  path: "/application/open-reference/constants/category",
366
399
  kind: "json",
367
400
  description: "List constant categories and their API usage scopes",
401
+ expects: "list",
368
402
  },
369
403
  "reference.constant-list": {
370
404
  method: "POST",
371
405
  path: "/application/open-reference/constants/getList",
372
406
  kind: "json",
373
407
  description: "List all constant values of a category",
408
+ expects: "object",
374
409
  },
375
410
  "reference.concept-search": {
376
411
  method: "POST",
377
412
  path: "/application/open-reference/concepts/search",
378
413
  kind: "json",
379
414
  description: "Search concept (theme) IDs by keyword",
415
+ expects: "list",
380
416
  },
381
417
  "reference.sector-search": {
382
418
  method: "POST",
383
419
  path: "/application/open-reference/sectors/search",
384
420
  kind: "json",
385
421
  description: "Search sector IDs by keyword",
422
+ expects: "list",
386
423
  },
387
424
  "reference.sector-constituents": {
388
425
  method: "POST",
389
426
  path: "/application/open-reference/sectors/constituents",
390
427
  kind: "json",
391
428
  description: "List constituent securities of a sector",
429
+ expects: "list",
430
+ },
431
+ "reference.index-weight": {
432
+ method: "POST",
433
+ path: "/application/open-quote/index/constituent-weight",
434
+ kind: "json",
435
+ description: "List index constituents with their weights (%) on one trading date",
436
+ expects: "list",
392
437
  },
393
438
  "quote.day-kline": {
394
439
  method: "POST",
@@ -451,72 +496,84 @@ const ENDPOINT_DEFS = {
451
496
  path: "/application/open-fundamental/financial-report/income-statement/accumulated",
452
497
  kind: "json",
453
498
  description: "Query A-share income statement (accumulated)",
499
+ expects: "list",
454
500
  },
455
501
  "fundamental.income-statement-quarterly": {
456
502
  method: "POST",
457
503
  path: "/application/open-fundamental/financial-report/income-statement/quarterly",
458
504
  kind: "json",
459
505
  description: "Query A-share income statement (quarterly)",
506
+ expects: "list",
460
507
  },
461
508
  "fundamental.balance-sheet": {
462
509
  method: "POST",
463
510
  path: "/application/open-fundamental/financial-report/balance-sheet/accumulated",
464
511
  kind: "json",
465
512
  description: "Query A-share balance sheet (accumulated)",
513
+ expects: "list",
466
514
  },
467
515
  "fundamental.cash-flow": {
468
516
  method: "POST",
469
517
  path: "/application/open-fundamental/financial-report/cash-flow-statement/accumulated",
470
518
  kind: "json",
471
519
  description: "Query A-share cash flow statement (accumulated)",
520
+ expects: "list",
472
521
  },
473
522
  "fundamental.cash-flow-quarterly": {
474
523
  method: "POST",
475
524
  path: "/application/open-fundamental/financial-report/cash-flow-statement/quarterly",
476
525
  kind: "json",
477
526
  description: "Query A-share cash flow statement (quarterly)",
527
+ expects: "list",
478
528
  },
479
529
  "fundamental.income-statement-hk": {
480
530
  method: "POST",
481
531
  path: "/application/open-fundamental/financial-report/income-statement/hk",
482
532
  kind: "json",
483
533
  description: "Query HK income statement (China GAAP)",
534
+ expects: "list",
484
535
  },
485
536
  "fundamental.balance-sheet-hk": {
486
537
  method: "POST",
487
538
  path: "/application/open-fundamental/financial-report/balance-sheet/hk",
488
539
  kind: "json",
489
540
  description: "Query HK balance sheet (China GAAP)",
541
+ expects: "list",
490
542
  },
491
543
  "fundamental.cash-flow-hk": {
492
544
  method: "POST",
493
545
  path: "/application/open-fundamental/financial-report/cash-flow-statement/hk",
494
546
  kind: "json",
495
547
  description: "Query HK cash flow statement (China GAAP)",
548
+ expects: "list",
496
549
  },
497
550
  "fundamental.income-statement-us": {
498
551
  method: "POST",
499
552
  path: "/application/open-fundamental/financial-report/income-statement/us",
500
553
  kind: "json",
501
554
  description: "Query US income statement",
555
+ expects: "list",
502
556
  },
503
557
  "fundamental.balance-sheet-us": {
504
558
  method: "POST",
505
559
  path: "/application/open-fundamental/financial-report/balance-sheet/us",
506
560
  kind: "json",
507
561
  description: "Query US balance sheet",
562
+ expects: "list",
508
563
  },
509
564
  "fundamental.cash-flow-us": {
510
565
  method: "POST",
511
566
  path: "/application/open-fundamental/financial-report/cash-flow-statement/us",
512
567
  kind: "json",
513
568
  description: "Query US cash flow statement",
569
+ expects: "list",
514
570
  },
515
571
  "fundamental.main-business": {
516
572
  method: "POST",
517
573
  path: "/application/open-fundamental/main-business",
518
574
  kind: "json",
519
575
  description: "Query main business composition",
576
+ expects: "list",
520
577
  },
521
578
  "fundamental.valuation-analysis": {
522
579
  method: "POST",
@@ -530,6 +587,7 @@ const ENDPOINT_DEFS = {
530
587
  path: "/application/open-fundamental/capital-structure/top-holders",
531
588
  kind: "json",
532
589
  description: "Query top holders (top10 / top10 float)",
590
+ expects: "list",
533
591
  },
534
592
  "fundamental.earning-forecast": {
535
593
  method: "POST",
@@ -621,7 +679,7 @@ const ENDPOINT_DEFS = {
621
679
  path: "/application/open-fundamental/bond/rating-change",
622
680
  kind: "json",
623
681
  description: "Query bond rating change history (max 10 bonds per call)",
624
- billing: { per: "security", price: 0.4, maxUnits: 10 },
682
+ billing: { per: "security", price: 0.4, maxUnits: 10, unitsFrom: ["securityList"] },
625
683
  retry: "no-replay",
626
684
  expects: "list",
627
685
  },
@@ -630,7 +688,7 @@ const ENDPOINT_DEFS = {
630
688
  path: "/application/open-fundamental/bond/issuer-rating-change",
631
689
  kind: "json",
632
690
  description: "Query issuer rating change history (by bond code or issuer name)",
633
- billing: { per: "issuer", price: 0.4, maxUnits: 10 },
691
+ billing: { per: "issuer", price: 0.4, maxUnits: 10, unitsFrom: ["securityList", "issuerNameList"] },
634
692
  retry: "no-replay",
635
693
  expects: "list",
636
694
  },
@@ -828,7 +886,7 @@ const ENDPOINT_DEFS = {
828
886
  path: "/application/open-ai/stock-summary/getList",
829
887
  kind: "json",
830
888
  description: "Stock highlights (refined research summary per security)",
831
- billing: { per: "row", price: 1, maxUnits: 6000 },
889
+ billing: { per: "row", price: 1, maxUnits: 6000, unitsFrom: ["securityList"] },
832
890
  retry: "no-replay",
833
891
  timeoutMs: 120_000,
834
892
  },
@@ -922,6 +980,18 @@ const ENDPOINT_DEFS = {
922
980
  pagination: { enabled: true, maxPageSize: 20 },
923
981
  retry: "no-replay",
924
982
  },
983
+ "ai.expert-intel.list": {
984
+ method: "POST",
985
+ path: "/application/open-insight/expertIntel/getList",
986
+ kind: "json",
987
+ description: "List AI expert intel (takeaways distilled from expert meetings and minutes)",
988
+ billing: { per: "row", price: 20 },
989
+ pagination: { enabled: true, maxPageSize: 50 },
990
+ rowId: "intelId",
991
+ rowIdUnverified: true,
992
+ retry: "no-replay",
993
+ expects: "list",
994
+ },
925
995
  "ai.management-discuss-announcement": {
926
996
  method: "POST",
927
997
  path: "/application/open-ai/management-discuss/from-announcement",
@@ -1012,6 +1082,7 @@ const ENDPOINT_DEFS = {
1012
1082
  path: "/application/open-vault/drive/getFolderList",
1013
1083
  kind: "json",
1014
1084
  description: "List the direct subfolders and files of a drive folder",
1085
+ expects: "object",
1015
1086
  },
1016
1087
  "vault.drive.create-folder": {
1017
1088
  method: "POST",
@@ -1103,6 +1174,13 @@ const ENDPOINT_DEFS = {
1103
1174
  pagination: { enabled: true, maxPageSize: 50, maxWindow: 10000 },
1104
1175
  rowId: "msgId",
1105
1176
  },
1177
+ "vault.wechat-message.download": {
1178
+ method: "GET",
1179
+ path: "/application/open-vault/wechatgroupmsg/download/file",
1180
+ kind: "download",
1181
+ description: "Download the file of a WeChat group message (document / image / audio / video)",
1182
+ fileOnSuccess: true,
1183
+ },
1106
1184
  "vault.wechat-chatroom.list": {
1107
1185
  method: "POST",
1108
1186
  path: "/application/open-vault/wechatgroupmsg/chatroomId",
@@ -1122,6 +1200,7 @@ const ENDPOINT_DEFS = {
1122
1200
  path: "/application/open-vault/stock-pool/getStockList",
1123
1201
  kind: "json",
1124
1202
  description: "List securities in stock pool(s)",
1203
+ expects: "array",
1125
1204
  },
1126
1205
  "vault.stock-pool.create": {
1127
1206
  method: "POST",
@@ -1192,13 +1271,14 @@ const ENDPOINT_DEFS = {
1192
1271
  path: "/application/open-alternative/EDB/search",
1193
1272
  kind: "json",
1194
1273
  description: "Search industry indicator list by keyword",
1274
+ expects: "array",
1195
1275
  },
1196
1276
  "alternative.edb-data": {
1197
1277
  method: "POST",
1198
1278
  path: "/application/open-alternative/EDB/getData",
1199
1279
  kind: "json",
1200
1280
  description: "Get industry indicator time-series data by indicator ID list",
1201
- billing: { per: "indicator", price: 30, maxUnits: 10 },
1281
+ billing: { per: "indicator", price: 30, maxUnits: 10, unitsFrom: ["indicatorIdList"] },
1202
1282
  },
1203
1283
  "alternative.concept-info": {
1204
1284
  method: "POST",
@@ -1238,6 +1318,7 @@ const ENDPOINT_DEFS = {
1238
1318
  kind: "json",
1239
1319
  description: "Search data indicators by keyword (returns indicatorCode + params)",
1240
1320
  retry: "no-999999",
1321
+ expects: "array",
1241
1322
  },
1242
1323
  "indicator.cross-section": {
1243
1324
  method: "POST",
@@ -55,9 +55,9 @@ const ERROR_HINTS = {
55
55
  "120001": "用 `gangtise reference securities-search` 确认代码与后缀(如 600519.SH / 00700.HK)。",
56
56
  "130001": "未找到数据——先核对查询条件;EDE 指标端点此码也可能是未开通该指标权限,仍失败联系客户经理。",
57
57
  "130002": "确认 ID 有效且本账号可见(云盘的文件 / 文件夹 ID 用 `vault drive-folder-list` 核对,笔记 ID 用 `vault stock-note-list` 核对;删除类命令收到此码可能是目标已被删掉);下载类还需检查 --file-type 取值是否合法。",
58
- "130003": "资源未生成或该条记录未附带文件——换一条有附件的记录(列表里 hasAttachment/hasFile 为 true 的),或稍后再试。",
58
+ "130003": "资源未生成或该条记录未附带文件——换一条有附件的记录(列表里 hasAttachment/hasFile 为 true 的),或稍后再试;`vault wechat-message-download` 收到此码是消息不存在、本账号无权访问或文件已不可用,重试不会变。",
59
59
  "130004": "下载 ID 需为数字,检查该命令的 --*-id 参数是否传对。",
60
- "130005": "对照命令 --help 检查 --file-type / --content-type 取值。",
60
+ "130005": "对照命令 --help 检查 --file-type / --content-type 取值;`vault wechat-message-download` 收到此码是该消息为 text / url、没有文件——只有 document / image / audio / video 消息可下载。",
61
61
  "140001": "稍后用对应 *-check 命令查询。",
62
62
  "140002": "终态失败,立即重试同一请求不会变。msg 指向参数(EDE 的指标参数名/枚举以 indicator search 的 parameterList 为准)就改参数重新提交;msg 是「业务处理失败」则与参数无关,稍后再试,持续出现请带 trace 报障。",
63
63
  "210001": "换一篇,或改用 list 取正文摘要。",
@@ -1,8 +1,16 @@
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;
13
+ const CHANGE_TOLERANCE = 0.005;
6
14
  const QUOTABLE = /^(\d{6}\.(SH|SZ|BJ)|\d{5}\.HK|[A-Z][A-Z0-9.]*\.(O|N|A))$/;
7
15
  const QUOTE_BATCH = 500;
8
16
  const CLOSES_WINDOW_DAYS = 10;
@@ -137,7 +145,8 @@ export async function fillComponentCodes(rows, lookup) {
137
145
  }
138
146
  });
139
147
  const quotable = [...new Set([...candidates.values()].flat().filter((c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code)).map((c) => c.code))];
140
- 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")]));
141
150
  const isQuotable = (c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code);
142
151
  const candidatesOf = (row) => candidates.get(row.componentName) ?? [];
143
152
  const marketsOf = (listed) => new Set(listed.map((c) => market(c.code)));
@@ -184,7 +193,30 @@ export async function fillComponentCodes(rows, lookup) {
184
193
  history.set(code, series);
185
194
  }
186
195
  }
187
- 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);
188
220
  for (const { own, etf } of etfs.values()) {
189
221
  for (const row of own) {
190
222
  const matched = unverified.get(row);
@@ -210,11 +242,35 @@ export async function fillComponentCodes(rows, lookup) {
210
242
  }
211
243
  }
212
244
  }
245
+ for (const { own, etf } of etfs.values()) {
246
+ for (const row of own) {
247
+ const change = row.pctChange;
248
+ if (codes.has(row) || typeof change !== "number" || typeof row.close !== "number")
249
+ continue;
250
+ const close = row.close;
251
+ const pool = candidatesOf(row).filter((c) => !etf.used.has(c.code));
252
+ if (!pool.length || !pool.every((c) => isQuotable(c) && history.has(c.code)))
253
+ continue;
254
+ const atClose = (c) => history.get(c.code).filter(([, price]) => agrees(close, price));
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)
263
+ continue;
264
+ const code = narrow(hit, etf);
265
+ if (code)
266
+ place(row, code, etf);
267
+ }
268
+ }
213
269
  for (const { own, etf } of etfs.values()) {
214
270
  const markets = new Set([...etf.used].map(market));
215
271
  let undecided = own.some((r) => failed.has(r.componentName));
216
272
  for (const r of own) {
217
- if (codes.get(r) || tentative.has(r))
273
+ if (codes.get(r))
218
274
  continue;
219
275
  const held = marketsOf(candidatesOf(r).filter((c) => !etf.used.has(c.code) && (typeof r.close !== "number" || mayBe(r, c))));
220
276
  if (held.size > 1)
@@ -329,14 +385,14 @@ export function clientLookup(client) {
329
385
  return Array.isArray(data?.list) ? data.list : [];
330
386
  },
331
387
  async closes(codes, startDate, endDate) {
332
- const data = normalizeRows(await client.call("quote.day-kline", { securityList: codes, startDate, endDate, fieldList: ["securityCode", "tradeDate", "close"], limit: 10_000 }));
388
+ const data = normalizeRows(await client.call("quote.day-kline", { securityList: codes, startDate, endDate, fieldList: ["securityCode", "tradeDate", "close", "preClose"], limit: 10_000 }));
333
389
  const list = Array.isArray(data) ? data : (data?.list ?? []);
334
390
  const out = new Map();
335
391
  for (const k of list) {
336
392
  if (typeof k.securityCode !== "string" || typeof k.tradeDate !== "string" || typeof k.close !== "number")
337
393
  continue;
338
394
  const series = out.get(k.securityCode) ?? [];
339
- series.push([k.tradeDate, k.close]);
395
+ series.push(typeof k.preClose === "number" && k.preClose > 0 ? [k.tradeDate, k.close, (k.close / k.preClose - 1) * 100] : [k.tradeDate, k.close]);
340
396
  out.set(k.securityCode, series);
341
397
  }
342
398
  for (const series of out.values())
@@ -347,10 +403,11 @@ export function clientLookup(client) {
347
403
  const data = normalizeRows(await client.call("quote.realtime", { securityList: codes }));
348
404
  const list = Array.isArray(data) ? data : (data?.list ?? []);
349
405
  const out = new Map();
406
+ const price = (value) => (typeof value === "number" ? value : undefined);
350
407
  for (const q of list) {
351
- const prices = [q.preClose, q.latestPrice].filter((p) => typeof p === "number");
352
- if (typeof q.securityCode === "string" && prices.length)
353
- 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);
354
411
  }
355
412
  return out;
356
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) {