gangtise-openapi-cli 0.48.0 → 0.49.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 +15 -8
- package/dist/src/cli.js +9 -3
- package/dist/src/commands/ai.js +2 -2
- package/dist/src/commands/fund.js +5 -3
- package/dist/src/commands/groups.js +2 -1
- package/dist/src/commands/indicator.js +1 -1
- package/dist/src/commands/shared.js +2 -1
- package/dist/src/commands/update.js +30 -0
- package/dist/src/core/endpoints.js +4 -4
- package/dist/src/core/etfComponents.js +301 -0
- package/dist/src/core/selfUpdate.js +94 -0
- package/dist/src/core/updateCheck.js +50 -14
- package/dist/src/version.js +5 -3
- package/gangtise-openapi/SKILL.md +6 -6
- package/gangtise-openapi/references/commands/ai.md +1 -1
- package/gangtise-openapi/references/commands/fund.md +6 -2
- package/gangtise-openapi/references/commands/indicator.md +2 -2
- package/gangtise-openapi/references/errors.md +1 -1
- package/gangtise-openapi/references/response-schema.md +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
README 仅列最近 5 个版本摘要:
|
|
8
8
|
|
|
9
|
+
- **v0.49.0 — 2026-10-28**:① **新增 `gangtise update`**:一条命令更新到最新版(从 npmjs.org `npm install -g`),并替换 `~/.claude` / `~/.codex` / `~/.openclaw/workspace` / `~/.hermes` 下版本较旧的 Agent Skill 副本;`--check` 只查不装,`--no-skill` 不动 skill 副本。② **新版本提示**:在终端里运行任何命令时,若已有新版本,结束后 stderr 提示一行(同一新版本约一天一次);版本信息在后台刷新、命令不等网络,脚本 / Agent / CI 中不提示,`GANGTISE_NO_UPDATE_CHECK=1` 关闭。③ **管理层情绪的 `publishTime` 现为北京时间**,与 `--start-time` / `--end-time` 同一口径;按 v0.48.0 说明「先加 8 小时」处理过的脚本要去掉这一步。④ **路演 / 调研 / 策略会 / 论坛列表的翻页去重改按各自的 ID**(`roadshowId` / `siteVisitId` / `strategyMeetingId` / `forumId`):跨页重复的行会被去掉、结果标为不完整(退出码 3),提示缩短时间范围重拉;`response-schema.md` 同步更正这四个列表的字段名。⑤ **公募基金**:`fund holder-structure` 的 `holderCount` 现为整数,按 v0.44.0 说明「先去千分位逗号再转数字」的处理要去掉(对整数做字符串替换会报错);`fund etf-pcf-components` 的接口只返回成分名称,CLI 按名称匹配补上 `componentCode`(带市场后缀,如 `600519.SH` / `00941.HK`;用该行收盘价核对,A+H 同名两行分别匹配;现金行等匹配不到的为 `null`,stderr 列出),`--no-component-code` 关闭。⑥ `ai security-clue` 的 `--query-mode` / `--gts-code` 补充帮助说明。
|
|
10
|
+
|
|
9
11
|
- **v0.48.0 — 2026-10-24**:① **新增管理层情绪三个命令**:`ai management-sentiment-indicator`(每场业绩会的正面回应程度 / 回复乐观度 / 回复确定度评分与按问题类型拆分)、`ai management-sentiment-conclusion`(情绪结论与较上一场的问答对照),各 5 积分/条、自动翻页;`ai management-sentiment-detail` 按列表返回的 `source.sourceId` 取逐条问答评分与依据,10 积分/次。⚠️ 返回的 `publishTime` 比北京时间早 8 小时(时间筛选按北京时间),按时刻展示或按日期归档前先加 8 小时。② **新增 `bond default-list` 违约债券**(0.4 积分/次):按违约日期区间(不超过 1 年)/ `--security` / `--issuer` 三选一;`--issuer` 一次一个、用发行人全称,简称只返回最匹配的一家主体,核对 `issuerName`。③ 文档:EDE 基金指标(分类、复权净值、区间收益与风险指标、同类排名),区间指标的参数须显式传。④ 依赖 `undici` 升级到 7.30.1(含上游安全修复)。
|
|
10
12
|
|
|
11
13
|
- **v0.47.0 — 2026-10-17**:① **`insight opinion list` 新增 `--category`(观点类别:业绩点评 / 异动点评 / 调研访谈更新 / 交易台 / 行业分析)与 `--broker-tag`(头部券商 / 新财富团队)**,返回行带 `categoryList` / `brokerTagList`。② **`--llm-tag` 改为 28 项投研业务标签**(强烈推荐、超预期、涨价、龙头等,取值见 `--help`);⚠️ **存量脚本要改**:业绩点评、头部券商、新财富团队改用 `--category earningsReview` / `--broker-tag topBroker` / `--broker-tag newFortune`,传给 `--llm-tag` 报 `100005`。同一选项传多个值取并集。③ `--with-content` 不支持 `--category` / `--broker-tag`(本地报错、不发请求),其 `--llm-tag` 仍只收旧的四个值;要按新标签筛又要正文,用 `list` + `detail`。④ **`bond daily-quote` / `bond valuation` 首行比起始日晚超过 14 天时 stderr 提示**(上市较晚或数据起点较晚;多只同查只看整批最早的首行,逐只起点按 `securityCode` 核对)。⑤ 文档:股东结构类 EDE 指标(`shr_*`),大股东系列须传 `rank`、户均持股系列须传 `shareType`。
|
|
@@ -14,10 +16,9 @@ README 仅列最近 5 个版本摘要:
|
|
|
14
16
|
|
|
15
17
|
- **v0.45.0 — 2026-10-07**:① **`vault drive-copy` 可复制整个文件夹**:`--folder-id` + `--target-parent-id`,连同子文件夹与文件复制到另一空间,返回 `{folderId, newFolderId}`;复制文件的写法不变,两组参数二选一。② **下载文件名**:响应同时给出 UTF-8 文件名与普通文件名时用 UTF-8 的那个。③ `GANGTISE_BASE_URL` 指向非本机地址却用 `http://` 时 stderr 提示一次(凭证会明文传输)。④ 文档:`bond daily-quote` / `bond valuation` 的数据可能比账号窗口短,**按年份取长序列时核对首行日期**;`bond announcement` 按日期查询时有代码的行都带后缀;基金只知道名称时怎么换代码;分钟 K 的可查历史是滚动的短窗口。
|
|
16
18
|
|
|
17
|
-
- **v0.44.0 — 2026-09-30**:**新增 `fund` 公募基金 18 个命令,一律 0.4 积分/次**(按次,与返回行数、基金只数无关):`basic-info` / `nav`(日频净值)/ `fee-rate` / `manager-info`(按姓名精确匹配,同名全返回)/ `manager-history` / `asset-size` / `holder-structure` / `top10-holders` / `asset-allocation` / `stock-portfolio` / `industry-allocation` / `bond-portfolio` / `bond-type-allocation` / `fund-portfolio` / `fund-type-allocation` / `etf-pcf-header` / `etf-pcf-components` / `etf-share-change`。`--security` 用带大写后缀的基金代码(场外 `.OF`,场内 `.SH` / `.SZ`),**代码不存在、不带后缀或后缀小写都返回空结果、不报错**。不分页:单次上限 10000 行,超出整批报 `100006`;日期两端都不传取账号可回溯窗口内的全部,起点早于窗口整批报 `110003`。**整族超时 / 5xx 不自动重发**(避免重复扣费)。取数前注意:各命令单位不同(元 / 万元 / 万份 / 万股);`holder-structure` 的 `holderCount` 是带千分位的字符串;持仓明细只返回证券简称、不返回代码;`fund-type-allocation` 季报期只含重仓基金。逐条见 `gangtise-openapi/references/commands/fund.md`。**另有几处行为变化**:只收一个值的参数重复传直接报错;代码类列表也按顿号 / 分号 / 空格分隔并去重;显式 `--size` 与 `fundamental earning-forecast` 的日期区间估算超过 1000 积分同样要 `--yes`;jsonl / csv 文件一律以换行结尾,csv 不再给 `-3.5%` 这类值加 `'`;全市场分片早于账号窗口的部分跳过而不中止;接口返回下载链接时不带 `--output` 也会下载;A 股公告与 `ai knowledge-batch` 只写日期的 `--end-time` 按当日 23:59:59 换算;`--indicator-param` 里的日期值与 `--date` 同样校验;单次请求另有总时长上限(`GANGTISE_TIMEOUT_MS` 的 2 倍,下载与上传 60 倍);环境变量设成空字符串按未设置处理。完整列表见 CHANGELOG。
|
|
18
|
-
|
|
19
19
|
### 历史里程碑
|
|
20
20
|
|
|
21
|
+
- **v0.44.0**:新增公募基金 `fund` 命令组 18 个命令(0.4 积分/次),额度保护覆盖显式 `--size`。
|
|
21
22
|
- **v0.43.0**:按条计费列表的额度保护(省略 `--size` 时估算超过 1000 积分须 `--yes`);翻页结果的跨页重复、版本变化与 `total` 封顶检测。
|
|
22
23
|
- **v0.42.0**:数值参数只收十进制写法(`0x10` / `1e3` 这类写法发请求前报错);接管道时保留退出码 `3` / `4`。
|
|
23
24
|
- **v0.41.0**:新增债券 12 个命令与云盘管理 9 个命令;观点列表改走 v2(列表只含摘要 1 积分/条,正文按 ID 另取);新增会议线索与联网搜索。
|
|
@@ -48,13 +49,17 @@ npm install -g gangtise-openapi-cli
|
|
|
48
49
|
gangtise --help
|
|
49
50
|
```
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
更新到最新版:
|
|
52
53
|
|
|
53
54
|
```bash
|
|
54
|
-
|
|
55
|
+
gangtise update # 从 npmjs.org 安装最新版(npm install -g),并更新已安装的 Agent Skill 副本
|
|
56
|
+
gangtise update --check # 只查看有没有新版本,不安装
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
- `update` 命令从 v0.49.0 起提供,更早的版本先运行一次 `npm install -g gangtise-openapi-cli@latest`
|
|
60
|
+
- `gangtise update` 只更新通过 `npm install -g` 安装的 CLI;从仓库 clone 运行的,按原方式 `git pull` 后重新构建。npm 报权限错误(`EACCES`)时,按提示用 `sudo npm install -g …` 安装后,再运行一次 `gangtise update` 更新 Agent Skill 副本
|
|
61
|
+
- Agent Skill:`~/.claude/skills/`、`~/.codex/skills/`、`~/.openclaw/workspace/skills/`、`~/.hermes/skills/` 下版本低于新版的 `gangtise-openapi` 副本会被替换(**对副本做过的本地修改会被覆盖**);版本相同或更新的副本、符号链接、以及其他目录下的副本不动。加 `--no-skill` 则不碰 skill 副本
|
|
62
|
+
- 在终端里运行任何命令时,若已有新版本,命令结束后 stderr 提示一行(同一个新版本大约一天提示一次)。版本信息在后台刷新,命令本身不等待网络;输出不是终端时(脚本、Agent、CI)不提示。设 `GANGTISE_NO_UPDATE_CHECK=1` 可关闭提示与后台查询
|
|
58
63
|
|
|
59
64
|
本地开发:
|
|
60
65
|
|
|
@@ -80,6 +85,7 @@ export GANGTISE_PAGE_CONCURRENCY=5 # 翻页/分片并发数(默认 5,上
|
|
|
80
85
|
export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
|
|
81
86
|
export GANGTISE_TIMEOUT_MS=30000 # 请求超时,整数毫秒(默认 30s;上限 1 小时;低于 1 秒或写法非法时用默认值;未按所写数值生效时在 stderr 提示)。按「多久没收到数据」计时,另有单次请求总时长上限:普通请求为它的 2 倍,下载与上传为 60 倍。one-pager 等生成类命令至少 120 秒、上传类至少 300 秒,设得更短不生效
|
|
82
87
|
export GANGTISE_TOKEN_CACHE_PATH=... # 覆盖 token 缓存路径(默认 ~/.config/gangtise/token.json)
|
|
88
|
+
export GANGTISE_NO_UPDATE_CHECK=1 # 关闭新版本提示与后台版本查询
|
|
83
89
|
```
|
|
84
90
|
|
|
85
91
|
环境变量设成空字符串(如 `GANGTISE_BASE_URL=`)按未设置处理,取默认值。`GANGTISE_BASE_URL` 指向非本机地址却用 `http://` 时,CLI 在 stderr 提示一次:登录与请求里的 AK / SK / token 会明文传输。
|
|
@@ -137,7 +143,7 @@ install_skill ~/.hermes/skills # Hermes
|
|
|
137
143
|
|
|
138
144
|
> 从仓库 clone 开发时,把 `$SKILL_SRC` 换成仓库内 `gangtise-openapi` 目录的绝对路径即可。
|
|
139
145
|
|
|
140
|
-
> **版本更新**:`SKILL.md` 头部的 `version` 字段与 CLI 版本一致。`
|
|
146
|
+
> **版本更新**:`SKILL.md` 头部的 `version` 字段与 CLI 版本一致。`gangtise update` 会一并替换上面四个目录里版本较旧的副本;装在其他目录的副本,更新 CLI 后重新执行上面这段,再看已安装副本的 `version` 是否与 `gangtise --version` 相同。对已安装副本做过的本地修改会被覆盖。
|
|
141
147
|
|
|
142
148
|
安装后,可以用自然语言触发,例如:
|
|
143
149
|
- "帮我查今天所有的研报"
|
|
@@ -272,6 +278,7 @@ install_skill ~/.hermes/skills # Hermes
|
|
|
272
278
|
- `gangtise reference ...`
|
|
273
279
|
- `gangtise tool ...`
|
|
274
280
|
- `gangtise raw call ...` / `gangtise raw list`
|
|
281
|
+
- `gangtise update`(更新 CLI 与 Agent Skill 副本)
|
|
275
282
|
|
|
276
283
|
## 推荐工作流
|
|
277
284
|
|
|
@@ -735,7 +742,7 @@ gangtise fund etf-share-change --security 510300.SH --start-date 2026-09-01 --en
|
|
|
735
742
|
|
|
736
743
|
> **基金命令一律按次计费 0.4 积分**(与返回行数、基金只数无关,多只基金合并成一次调用最省),超时 / 5xx 不自动重放。不分页:单次上限 10000 行,超出整批报 `100006`,减少只数或缩短区间分批查。**代码不存在、不带后缀或后缀小写都返回空结果、不报错**——拿到空先核对代码。日期两端都不传 = 取账号可回溯窗口内的全部;起点早于窗口整批报 `110003`。
|
|
737
744
|
|
|
738
|
-
> **取数前注意**:各命令单位不同(`nav` / `asset-allocation` 为元,`asset-size` 为万份 / 万元,持仓明细为万股 / 万张 /
|
|
745
|
+
> **取数前注意**:各命令单位不同(`nav` / `asset-allocation` 为元,`asset-size` 为万份 / 万元,持仓明细为万股 / 万张 / 万元);持仓明细只返回证券简称、不返回代码;ETF 申赎成分的 `componentCode` 由 CLI 按名称匹配补上(匹配不到为 `null`,`--no-component-code` 关闭);`fund-type-allocation` 季报期只含重仓基金(`positionType: top`),中报 / 年报为全部持基(`all`)。逐条见 `gangtise-openapi/references/commands/fund.md`。
|
|
739
746
|
|
|
740
747
|
### AI
|
|
741
748
|
|
|
@@ -762,7 +769,7 @@ gangtise ai management-discuss-announcement --report-date 2025-12-31 --security-
|
|
|
762
769
|
gangtise ai management-discuss-announcement --report-date 2025-12-31 --security-code 000001.SZ --dimension all
|
|
763
770
|
# 管理层讨论-业绩会
|
|
764
771
|
gangtise ai management-discuss-earnings-call --report-date 2025-06-30 --security-code 000001.SZ --dimension financialPerformance
|
|
765
|
-
# 管理层情绪:指标与结论按条计费(5 积分/条),记得带 --size
|
|
772
|
+
# 管理层情绪:指标与结论按条计费(5 积分/条),记得带 --size;publishTime 与时间筛选都是北京时间
|
|
766
773
|
gangtise ai management-sentiment-indicator --security 300285.SZ --size 5
|
|
767
774
|
gangtise ai management-sentiment-conclusion --security 300285.SZ --start-time 2026-08-01 --end-time 2026-08-31 --size 5
|
|
768
775
|
# 逐条问答的评分与依据:--source-id 取上面返回的 source.sourceId(10 积分/次)
|
package/dist/src/cli.js
CHANGED
|
@@ -4,19 +4,21 @@ import { ACCUMULATING_PARSERS, unknownChoiceMessage } from "./core/args.js";
|
|
|
4
4
|
import { ApiError, ValidationError } from "./core/errors.js";
|
|
5
5
|
import { removeStagingFiles } from "./core/output.js";
|
|
6
6
|
import { isVerbose, setVerbose } from "./core/transport.js";
|
|
7
|
-
import { checkForUpdate } from "./core/updateCheck.js";
|
|
7
|
+
import { checkForUpdate, noticeUpdate } from "./core/updateCheck.js";
|
|
8
8
|
import { CLI_VERSION } from "./version.js";
|
|
9
9
|
import { COMMAND_GROUPS } from "./commands/groups.js";
|
|
10
10
|
import { decidedExitCode, markFailed } from "./core/exitStatus.js";
|
|
11
11
|
const program = new Command();
|
|
12
|
+
let ranCommand;
|
|
12
13
|
program
|
|
13
14
|
.name("gangtise")
|
|
14
15
|
.description("Gangtise OpenAPI CLI")
|
|
15
16
|
.version(CLI_VERSION)
|
|
16
17
|
.option("--verbose", "Print per-request timings to stderr (also: GANGTISE_VERBOSE=1)")
|
|
17
|
-
.hook("preAction", (thisCommand) => {
|
|
18
|
+
.hook("preAction", (thisCommand, actionCommand) => {
|
|
18
19
|
if (thisCommand.opts().verbose)
|
|
19
20
|
setVerbose(true);
|
|
21
|
+
ranCommand = actionCommand;
|
|
20
22
|
});
|
|
21
23
|
for (const group of COMMAND_GROUPS) {
|
|
22
24
|
program.addCommand(group);
|
|
@@ -100,7 +102,7 @@ async function main() {
|
|
|
100
102
|
const firstArg = process.argv[2];
|
|
101
103
|
if (firstArg === "--version" || firstArg === "-V") {
|
|
102
104
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
103
|
-
if (process.stdout.isTTY) {
|
|
105
|
+
if (process.stdout.isTTY && !process.env.GANGTISE_NO_UPDATE_CHECK) {
|
|
104
106
|
await Promise.race([checkForUpdate(CLI_VERSION), new Promise((resolve) => setTimeout(resolve, 2500))]);
|
|
105
107
|
process.exit(0);
|
|
106
108
|
}
|
|
@@ -125,5 +127,9 @@ async function main() {
|
|
|
125
127
|
process.stderr.write("Unknown error\n");
|
|
126
128
|
markFailed();
|
|
127
129
|
}
|
|
130
|
+
finally {
|
|
131
|
+
if (process.stderr.isTTY && !process.env.CI && !process.env.GANGTISE_NO_UPDATE_CHECK && ranCommand?.name() !== "update")
|
|
132
|
+
noticeUpdate(CLI_VERSION);
|
|
133
|
+
}
|
|
128
134
|
}
|
|
129
135
|
void main();
|
package/dist/src/commands/ai.js
CHANGED
|
@@ -64,8 +64,8 @@ query(ai, "security-clue", {
|
|
|
64
64
|
from(), size(),
|
|
65
65
|
field(new Option("--start-time <datetime>", "Start time").argParser(datetimeArg("--start-time")).makeOptionMandatory(), (v) => ({ startTime: v })),
|
|
66
66
|
field(new Option("--end-time <datetime>", "End time").argParser(datetimeArg("--end-time")).makeOptionMandatory(), (v) => ({ endTime: v })),
|
|
67
|
-
field(new Option("--query-mode <mode>").choices(["bySecurity", "byIndustry"]).makeOptionMandatory(), (v) => ({ queryMode: v })),
|
|
68
|
-
list("--gts-code <code>", "
|
|
67
|
+
field(new Option("--query-mode <mode>", "bySecurity: --gts-code takes stock codes; byIndustry: Shenwan industry codes").choices(["bySecurity", "byIndustry"]).makeOptionMandatory(), (v) => ({ queryMode: v })),
|
|
68
|
+
list("--gts-code <code>", "Stock code (e.g. 600519.SH) or Shenwan industry code (e.g. 821035.SWI), per --query-mode; repeatable", "gtsCodeList"),
|
|
69
69
|
choiceList("--source <name>", "Source: researchReport/conference/announcement/view (repeat)", "source", ["researchReport", "conference", "announcement", "view"]),
|
|
70
70
|
format(), output(),
|
|
71
71
|
],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { collectNames } from "../core/args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { addComponentCodes, clientLookup } from "../core/etfComponents.js";
|
|
4
|
+
import { date, flag, format, list, output, query, requiredList, value } from "./shared.js";
|
|
4
5
|
export const fund = new Command("fund").description("Fund APIs: profiles, NAV, fees, managers, scale, holders, holdings and allocation, ETF creation/redemption");
|
|
5
6
|
const code = () => requiredList("--security <code>", "Fund code, e.g. 005827.OF / 159967.SZ / 510300.SH (repeatable, comma-separated accepted). From a fund name: 'reference securities-search --category fund', keeping only rows whose category is fund", "fundCodeList");
|
|
6
7
|
const range = (what) => [
|
|
@@ -89,9 +90,10 @@ query(fund, "etf-pcf-header", {
|
|
|
89
90
|
fields: [code(), format(), output()],
|
|
90
91
|
});
|
|
91
92
|
query(fund, "etf-pcf-components", {
|
|
92
|
-
description: "ETF creation/redemption component list for the latest trade date: quantities, weights, cash substitution",
|
|
93
|
+
description: "ETF creation/redemption component list for the latest trade date: quantities, weights, cash substitution. componentCode is matched by the CLI from each component name",
|
|
93
94
|
endpoint: "fund.etf-pcf-components",
|
|
94
|
-
fields: [code(), format(), output()],
|
|
95
|
+
fields: [code(), format(), output(), flag("--no-component-code", "Skip matching componentCode: no download of the securities list, no name searches, no quote request")],
|
|
96
|
+
shape: (data, client, options) => options.componentCode === false ? Promise.resolve(data) : addComponentCodes(data, clientLookup(client)),
|
|
95
97
|
});
|
|
96
98
|
query(fund, "etf-share-change", {
|
|
97
99
|
description: "ETF shares and scale per trade date, with the change vs the previous one",
|
|
@@ -10,5 +10,6 @@ import { quote } from "./quote.js";
|
|
|
10
10
|
import { raw } from "./raw.js";
|
|
11
11
|
import { reference } from "./reference.js";
|
|
12
12
|
import { tool } from "./tool.js";
|
|
13
|
+
import { update } from "./update.js";
|
|
13
14
|
import { vault } from "./vault.js";
|
|
14
|
-
export const COMMAND_GROUPS = [auth, lookup, insight, quote, fundamental, bond, fund, reference, vault, ai, alternative, indicator, tool, raw];
|
|
15
|
+
export const COMMAND_GROUPS = [auth, lookup, insight, quote, fundamental, bond, fund, reference, vault, ai, alternative, indicator, tool, raw, update];
|
|
@@ -28,7 +28,7 @@ function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
|
|
|
28
28
|
parts.push(`indicators ${indicators.join(", ")}`);
|
|
29
29
|
if (securities.length > 0)
|
|
30
30
|
parts.push(`securities ${securities.join(", ")}`);
|
|
31
|
-
process.stderr.write(`[gangtise] warning: the response omits ${parts.join(" and ")} entirely — no row/column at all, not a null one. A code the server merely has no data for still comes back as null, so this normally means the code itself was not recognised: check it for typos and, for securities, for the wrong market suffix (US tickers are .O/.N, not .US). Result marked partial (exit 3).\n`);
|
|
31
|
+
process.stderr.write(`[gangtise] warning: the response omits ${parts.join(" and ")} entirely — no row/column at all, not a null one. A code the server merely has no data for still comes back as null, so this normally means the code itself was not recognised: check it for typos and, for securities, for the wrong market suffix (US tickers are .O/.N/.A, not .US). Result marked partial (exit 3).\n`);
|
|
32
32
|
}
|
|
33
33
|
function requireIndicatorScope(indicators, securities) {
|
|
34
34
|
const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
|
|
@@ -221,6 +221,7 @@ export function query(parent, name, spec) {
|
|
|
221
221
|
return command.action(async (options) => {
|
|
222
222
|
const body = requestBody(fields, options);
|
|
223
223
|
const endpoint = typeof spec.endpoint === "string" ? spec.endpoint : spec.endpoint(options);
|
|
224
|
-
|
|
224
|
+
const shape = spec.shape;
|
|
225
|
+
return emit(options, shape ? async (client) => shape(await client.call(endpoint, body), client, options) : (client) => client.call(endpoint, body), spec.cache);
|
|
225
226
|
});
|
|
226
227
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { isVersionNewer } from "../core/args.js";
|
|
4
|
+
import { installVersion, refreshSkillCopies } from "../core/selfUpdate.js";
|
|
5
|
+
import { latestVersion, PACKAGE_NAME, REGISTRY } from "../core/updateCheck.js";
|
|
6
|
+
import { CLI_VERSION, PACKAGE_ROOT } from "../version.js";
|
|
7
|
+
export const update = new Command("update")
|
|
8
|
+
.description("Update to the latest published version (npm install -g), then refresh older copies of the Agent Skill installed for Claude Code / Codex / OpenClaw / Hermes")
|
|
9
|
+
.option("--check", "Only report whether a newer version is published; install nothing")
|
|
10
|
+
.option("--no-skill", "Leave the Agent Skill copies as they are")
|
|
11
|
+
.action(async (options) => {
|
|
12
|
+
const latest = await latestVersion(undefined, 15_000, Date.now(), true);
|
|
13
|
+
if (!latest)
|
|
14
|
+
throw new Error(`Could not reach ${REGISTRY} to look up the latest version. Check the network and retry, or install directly: npm install -g ${PACKAGE_NAME}@latest --registry=${REGISTRY}`);
|
|
15
|
+
const outdated = isVersionNewer(latest, CLI_VERSION);
|
|
16
|
+
if (options.check) {
|
|
17
|
+
process.stdout.write(outdated ? `Update available: ${CLI_VERSION} → ${latest}. Run: gangtise update\n` : `gangtise ${CLI_VERSION} is the latest version.\n`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (outdated) {
|
|
21
|
+
installVersion(latest, PACKAGE_ROOT);
|
|
22
|
+
process.stdout.write(`Updated gangtise ${CLI_VERSION} → ${latest}.\n`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
process.stdout.write(`gangtise ${CLI_VERSION} is the latest version.\n`);
|
|
26
|
+
}
|
|
27
|
+
if (options.skill)
|
|
28
|
+
for (const line of refreshSkillCopies(PACKAGE_ROOT, os.homedir()))
|
|
29
|
+
process.stdout.write(`${line}\n`);
|
|
30
|
+
});
|
|
@@ -114,7 +114,7 @@ const ENDPOINT_DEFS = {
|
|
|
114
114
|
description: "List roadshows",
|
|
115
115
|
billing: { per: "row", price: 10 },
|
|
116
116
|
pagination: { enabled: true, maxPageSize: 50 },
|
|
117
|
-
rowId: "
|
|
117
|
+
rowId: "roadshowId",
|
|
118
118
|
rowIdUnverified: true,
|
|
119
119
|
},
|
|
120
120
|
"insight.site-visit.list": {
|
|
@@ -124,7 +124,7 @@ const ENDPOINT_DEFS = {
|
|
|
124
124
|
description: "List site visits",
|
|
125
125
|
billing: { per: "row", price: 10 },
|
|
126
126
|
pagination: { enabled: true, maxPageSize: 50 },
|
|
127
|
-
rowId: "
|
|
127
|
+
rowId: "siteVisitId",
|
|
128
128
|
rowIdUnverified: true,
|
|
129
129
|
},
|
|
130
130
|
"insight.strategy.list": {
|
|
@@ -134,7 +134,7 @@ const ENDPOINT_DEFS = {
|
|
|
134
134
|
description: "List strategy meetings",
|
|
135
135
|
billing: { per: "row", price: 10 },
|
|
136
136
|
pagination: { enabled: true, maxPageSize: 50 },
|
|
137
|
-
rowId: "
|
|
137
|
+
rowId: "strategyMeetingId",
|
|
138
138
|
rowIdUnverified: true,
|
|
139
139
|
},
|
|
140
140
|
"insight.forum.list": {
|
|
@@ -144,7 +144,7 @@ const ENDPOINT_DEFS = {
|
|
|
144
144
|
description: "List forums",
|
|
145
145
|
billing: { per: "row", price: 10 },
|
|
146
146
|
pagination: { enabled: true, maxPageSize: 50 },
|
|
147
|
-
rowId: "
|
|
147
|
+
rowId: "forumId",
|
|
148
148
|
rowIdUnverified: true,
|
|
149
149
|
},
|
|
150
150
|
"insight.performance-calendar.list": {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { beijingDateString } from "./args.js";
|
|
2
|
+
import { normalizeRows } from "./normalize.js";
|
|
3
|
+
import { PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
|
|
4
|
+
const PRICE_TOLERANCE = 0.0005;
|
|
5
|
+
const agrees = (close, price) => price > 0 && Math.abs(close - price) / price <= PRICE_TOLERANCE;
|
|
6
|
+
const QUOTABLE = /^(\d{6}\.(SH|SZ|BJ)|\d{5}\.HK|[A-Z][A-Z0-9.]*\.(O|N|A))$/;
|
|
7
|
+
const QUOTE_BATCH = 500;
|
|
8
|
+
const CLOSES_WINDOW_DAYS = 10;
|
|
9
|
+
const CLOSES_BATCH = 1000;
|
|
10
|
+
const squash = (name) => name.replace(/\s+/g, "");
|
|
11
|
+
const base = (name) => squash(name).replace(/-[A-Z]+$/, "");
|
|
12
|
+
const ticker = (code) => code.slice(0, code.lastIndexOf("."));
|
|
13
|
+
const exchange = (code) => code.slice(code.lastIndexOf(".") + 1);
|
|
14
|
+
const market = (code) => {
|
|
15
|
+
const suffix = exchange(code);
|
|
16
|
+
return ["SH", "SZ", "BJ"].includes(suffix) ? "CN" : ["O", "N", "A"].includes(suffix) ? "US" : suffix;
|
|
17
|
+
};
|
|
18
|
+
function toCandidates(rows) {
|
|
19
|
+
return rows.flatMap((row) => {
|
|
20
|
+
const r = row;
|
|
21
|
+
return typeof r.gtsCode === "string" && typeof r.gtsName === "string" && r.category !== "index"
|
|
22
|
+
? [{ code: r.gtsCode, name: r.gtsName, category: String(r.category), prefix: false }]
|
|
23
|
+
: [];
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function candidatesFor(name, lookup) {
|
|
27
|
+
const key = squash(name);
|
|
28
|
+
const found = toCandidates(await lookup.search(key));
|
|
29
|
+
const exact = found.filter((c) => base(c.name) === base(key) || ticker(c.code) === key);
|
|
30
|
+
if (/^[A-Z][A-Z0-9]{0,6}$/.test(key) && !exact.some((c) => market(c.code) === "US")) {
|
|
31
|
+
const bySuffix = (await Promise.all(["O", "N"].map((suffix) => lookup.search(`${key}.${suffix}`))))
|
|
32
|
+
.flatMap((rows) => toCandidates(rows))
|
|
33
|
+
.filter((c) => ticker(c.code) === key);
|
|
34
|
+
exact.push(...bySuffix);
|
|
35
|
+
}
|
|
36
|
+
const prefix = base(key).length >= 2 ? found.filter((c) => !exact.includes(c) && base(c.name).startsWith(base(key))) : [];
|
|
37
|
+
return [...exact, ...prefix.map((c) => ({ ...c, prefix: true }))];
|
|
38
|
+
}
|
|
39
|
+
function narrow(candidates, etf) {
|
|
40
|
+
let left = candidates;
|
|
41
|
+
if (new Set(left.map((c) => market(c.code))).size > 1) {
|
|
42
|
+
if (!etf.market)
|
|
43
|
+
return null;
|
|
44
|
+
left = left.filter((c) => market(c.code) === etf.market);
|
|
45
|
+
}
|
|
46
|
+
if (left.length > 1) {
|
|
47
|
+
const same = left.filter((c) => exchange(c.code) === etf.exchange);
|
|
48
|
+
if (same.length)
|
|
49
|
+
left = same;
|
|
50
|
+
}
|
|
51
|
+
return left.length === 1 ? left[0].code : null;
|
|
52
|
+
}
|
|
53
|
+
function byPrice(row, candidates, prices, etf, unchecked) {
|
|
54
|
+
const close = typeof row.close === "number" ? row.close : undefined;
|
|
55
|
+
if (close === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
const open = candidates.filter((c) => !etf.used.has(c.code));
|
|
58
|
+
for (const tier of [open.filter((c) => !c.prefix), open.filter((c) => c.prefix)]) {
|
|
59
|
+
if (tier.some((c) => !prices.get(c.code)?.some((p) => p > 0) && unchecked(c)))
|
|
60
|
+
return undefined;
|
|
61
|
+
const matched = tier.filter((c) => prices.get(c.code)?.some((p) => agrees(close, p)));
|
|
62
|
+
if (matched.length)
|
|
63
|
+
return narrow(matched, etf);
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
function byName(candidates, etf) {
|
|
68
|
+
if (!etf.market)
|
|
69
|
+
return null;
|
|
70
|
+
const pool = candidates.filter((c) => !c.prefix && !etf.used.has(c.code) && market(c.code) === etf.market);
|
|
71
|
+
return pool.length ? narrow(pool, etf) : null;
|
|
72
|
+
}
|
|
73
|
+
async function splitting(codes, fetch) {
|
|
74
|
+
try {
|
|
75
|
+
return await fetch(codes);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (codes.length === 1 || error?.code !== "120001")
|
|
79
|
+
return new Map();
|
|
80
|
+
const half = Math.ceil(codes.length / 2);
|
|
81
|
+
const first = await splitting(codes.slice(0, half), fetch);
|
|
82
|
+
return new Map([...first, ...await splitting(codes.slice(half), fetch)]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const inBatches = (codes, size) => Array.from({ length: Math.ceil(codes.length / size) }, (_, i) => codes.slice(i * size, (i + 1) * size));
|
|
86
|
+
function listDates(rows, candidates, history) {
|
|
87
|
+
const votes = new Map();
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if (typeof row.close !== "number")
|
|
90
|
+
continue;
|
|
91
|
+
const exact = (candidates.get(row.componentName) ?? []).filter((c) => !c.prefix);
|
|
92
|
+
if (exact.length !== 1)
|
|
93
|
+
continue;
|
|
94
|
+
const tally = votes.get(market(exact[0].code)) ?? new Map();
|
|
95
|
+
for (const [date, close] of history.get(exact[0].code) ?? []) {
|
|
96
|
+
if (agrees(row.close, close))
|
|
97
|
+
tally.set(date, (tally.get(date) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
votes.set(market(exact[0].code), tally);
|
|
100
|
+
}
|
|
101
|
+
const dates = new Map();
|
|
102
|
+
for (const [where, tally] of votes) {
|
|
103
|
+
const ranked = [...tally].sort((a, b) => b[1] - a[1]);
|
|
104
|
+
if (ranked.length && ranked[0][1] >= 2 && ranked[1]?.[1] !== ranked[0][1])
|
|
105
|
+
dates.set(where, ranked[0][0]);
|
|
106
|
+
}
|
|
107
|
+
return dates;
|
|
108
|
+
}
|
|
109
|
+
function closesOn(dates, history) {
|
|
110
|
+
const out = new Map();
|
|
111
|
+
for (const [code, series] of history) {
|
|
112
|
+
const date = dates.get(market(code));
|
|
113
|
+
if (!date)
|
|
114
|
+
continue;
|
|
115
|
+
const last = series.filter(([d]) => d <= date).at(-1);
|
|
116
|
+
if (last)
|
|
117
|
+
out.set(code, [last[1]]);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
const carriesCode = (row) => typeof row.componentCode === "string" && row.componentCode !== "";
|
|
122
|
+
const needsCode = (row) => !carriesCode(row) && typeof row.componentName === "string";
|
|
123
|
+
export async function fillComponentCodes(rows, lookup) {
|
|
124
|
+
const pending = rows.filter(needsCode);
|
|
125
|
+
const names = [...new Set(pending.map((r) => r.componentName))];
|
|
126
|
+
const candidates = new Map();
|
|
127
|
+
await runWithConcurrency(names, PAGE_CONCURRENCY, async (name) => {
|
|
128
|
+
candidates.set(name, await candidatesFor(name, lookup).catch(() => []));
|
|
129
|
+
});
|
|
130
|
+
const quotable = [...new Set([...candidates.values()].flat().filter((c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code)).map((c) => c.code))];
|
|
131
|
+
const prices = new Map((await runWithConcurrency(inBatches(quotable, QUOTE_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.quotes(codes)))).flatMap((quoted) => [...quoted]));
|
|
132
|
+
const isQuotable = (c) => ["stock", "dr"].includes(c.category) && QUOTABLE.test(c.code);
|
|
133
|
+
const codes = new Map();
|
|
134
|
+
const etfs = new Map();
|
|
135
|
+
const place = (row, code, etf) => {
|
|
136
|
+
codes.set(row, code);
|
|
137
|
+
if (code)
|
|
138
|
+
etf.used.add(code);
|
|
139
|
+
};
|
|
140
|
+
for (const fundCode of new Set(pending.map((r) => r.fundCode))) {
|
|
141
|
+
const own = pending.filter((r) => r.fundCode === fundCode);
|
|
142
|
+
const etf = { exchange: exchange(String(fundCode)), used: new Set() };
|
|
143
|
+
etfs.set(fundCode, { own, etf });
|
|
144
|
+
for (const row of own) {
|
|
145
|
+
const code = byPrice(row, candidates.get(row.componentName) ?? [], prices, etf, isQuotable);
|
|
146
|
+
if (code !== undefined)
|
|
147
|
+
place(row, code, etf);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const open = (own) => own.filter((r) => !codes.has(r) && typeof r.close === "number" && (candidates.get(r.componentName) ?? []).some(isQuotable));
|
|
151
|
+
const lagging = [...etfs.values()].filter(({ own }) => open(own).length);
|
|
152
|
+
if (lagging.length && lookup.closes) {
|
|
153
|
+
const wanted = [...new Set(lagging.flatMap(({ own }) => own.flatMap((r) => (candidates.get(r.componentName) ?? []).filter(isQuotable).map((c) => c.code))))];
|
|
154
|
+
const end = beijingDateString(new Date());
|
|
155
|
+
const start = beijingDateString(new Date(Date.now() - CLOSES_WINDOW_DAYS * 86_400_000));
|
|
156
|
+
const history = new Map((await runWithConcurrency(inBatches(wanted, CLOSES_BATCH), PAGE_CONCURRENCY, (batch) => splitting(batch, (codes) => lookup.closes(codes, start, end)))).flatMap((part) => [...part]));
|
|
157
|
+
for (const { own, etf } of lagging) {
|
|
158
|
+
const dates = listDates(own, candidates, history);
|
|
159
|
+
const onDate = closesOn(dates, history);
|
|
160
|
+
for (const row of open(own)) {
|
|
161
|
+
const unchecked = (c) => isQuotable(c) && (history.get(c.code)?.some(([, close]) => agrees(row.close, close)) ?? true);
|
|
162
|
+
const code = byPrice(row, candidates.get(row.componentName) ?? [], onDate, etf, unchecked);
|
|
163
|
+
if (code !== undefined)
|
|
164
|
+
place(row, code, etf);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const { own, etf } of etfs.values()) {
|
|
169
|
+
const markets = new Set([...etf.used].map(market));
|
|
170
|
+
for (const r of own) {
|
|
171
|
+
const held = new Set((candidates.get(r.componentName) ?? []).filter((c) => !c.prefix).map((c) => market(c.code)));
|
|
172
|
+
if (held.size === 1)
|
|
173
|
+
markets.add([...held][0]);
|
|
174
|
+
}
|
|
175
|
+
if (markets.size === 1)
|
|
176
|
+
etf.market = [...markets][0];
|
|
177
|
+
for (const row of own)
|
|
178
|
+
if (!codes.has(row))
|
|
179
|
+
place(row, byName(candidates.get(row.componentName) ?? [], etf), etf);
|
|
180
|
+
}
|
|
181
|
+
const unresolved = [];
|
|
182
|
+
const filled = rows.map((row) => {
|
|
183
|
+
if (!codes.has(row))
|
|
184
|
+
return row;
|
|
185
|
+
const code = codes.get(row) ?? null;
|
|
186
|
+
if (!code)
|
|
187
|
+
unresolved.push({ fundCode: row.fundCode, serialNumber: row.serialNumber, componentName: row.componentName });
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const [key, value] of Object.entries(row)) {
|
|
190
|
+
if (key === "componentCode")
|
|
191
|
+
continue;
|
|
192
|
+
out[key] = value;
|
|
193
|
+
if (key === "serialNumber")
|
|
194
|
+
out.componentCode = code;
|
|
195
|
+
}
|
|
196
|
+
if (!("componentCode" in out))
|
|
197
|
+
out.componentCode = code;
|
|
198
|
+
return out;
|
|
199
|
+
});
|
|
200
|
+
return { rows: filled, unresolved };
|
|
201
|
+
}
|
|
202
|
+
export async function addComponentCodes(data, lookup) {
|
|
203
|
+
const list = data?.list;
|
|
204
|
+
if (!Array.isArray(list))
|
|
205
|
+
return data;
|
|
206
|
+
const rows = list;
|
|
207
|
+
const pending = rows.filter(needsCode).length;
|
|
208
|
+
if (!pending)
|
|
209
|
+
return data;
|
|
210
|
+
const filled = await fillComponentCodes(rows, lookup);
|
|
211
|
+
const left = filled.unresolved.map((u) => `${String(u.fundCode)} ${String(u.componentName)}`);
|
|
212
|
+
const shown = left.length > 20 ? `${left.slice(0, 20).join(", ")}, …` : left.join(", ");
|
|
213
|
+
process.stderr.write(`[gangtise] note: the API returns component names only; componentCode is the CLI's match for each name (looked up in the securities list, and checked against the component's close where it can be quoted): ${pending - left.length} of ${pending} rows.${left.length ? ` Left null (cash lines, commodities, names without a confident match): ${shown}` : ""}\n`);
|
|
214
|
+
return { ...data, list: filled.rows };
|
|
215
|
+
}
|
|
216
|
+
const MARKET_SECTORS = ["1000000002", "1000002615", "1000002618"];
|
|
217
|
+
export async function loadSecurityTable(fetchSector) {
|
|
218
|
+
try {
|
|
219
|
+
const markets = await Promise.all(MARKET_SECTORS.map(async (sectorId) => {
|
|
220
|
+
const data = await fetchSector(sectorId);
|
|
221
|
+
const list = (Array.isArray(data?.list) ? data.list : []).flatMap((row) => {
|
|
222
|
+
const r = row;
|
|
223
|
+
return typeof r.gtsCode === "string" && typeof r.gtsName === "string" ? [[r.gtsCode, r.gtsName]] : [];
|
|
224
|
+
});
|
|
225
|
+
if (!list.length)
|
|
226
|
+
throw new Error(`sector ${sectorId} listed nothing`);
|
|
227
|
+
return list;
|
|
228
|
+
}));
|
|
229
|
+
return markets.flat();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function tableSearch(list) {
|
|
236
|
+
const byBase = new Map();
|
|
237
|
+
const byTicker = new Map();
|
|
238
|
+
const byCode = new Map();
|
|
239
|
+
const add = (index, key, listing) => {
|
|
240
|
+
const listed = index.get(key);
|
|
241
|
+
if (listed)
|
|
242
|
+
listed.push(listing);
|
|
243
|
+
else
|
|
244
|
+
index.set(key, [listing]);
|
|
245
|
+
};
|
|
246
|
+
for (const listing of list) {
|
|
247
|
+
add(byBase, base(listing[1]), listing);
|
|
248
|
+
add(byTicker, ticker(listing[0]), listing);
|
|
249
|
+
byCode.set(listing[0], listing);
|
|
250
|
+
}
|
|
251
|
+
const bases = [...byBase.keys()];
|
|
252
|
+
return (keyword) => {
|
|
253
|
+
const key = squash(keyword);
|
|
254
|
+
const name = base(key);
|
|
255
|
+
const coded = byCode.get(key);
|
|
256
|
+
const exact = [...(byBase.get(name) ?? []), ...(byTicker.get(key) ?? []), ...(coded ? [coded] : [])];
|
|
257
|
+
if (!exact.length)
|
|
258
|
+
return undefined;
|
|
259
|
+
const prefix = name.length >= 2 ? bases.filter((b) => b !== name && b.startsWith(name)).flatMap((b) => byBase.get(b)) : [];
|
|
260
|
+
return [...new Set([...exact, ...prefix])].map(([gtsCode, gtsName]) => ({ gtsCode, gtsName, category: "stock" }));
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export function clientLookup(client) {
|
|
264
|
+
let table;
|
|
265
|
+
const local = () => (table ??= loadSecurityTable((sectorId) => client.call("reference.sector-constituents", { sectorId })).then((list) => list && tableSearch(list)));
|
|
266
|
+
return {
|
|
267
|
+
async search(keyword) {
|
|
268
|
+
const hits = (await local())?.(keyword);
|
|
269
|
+
if (hits)
|
|
270
|
+
return hits;
|
|
271
|
+
const data = await client.call("reference.securities-search", { keyword, top: 10 });
|
|
272
|
+
return Array.isArray(data?.list) ? data.list : [];
|
|
273
|
+
},
|
|
274
|
+
async closes(codes, startDate, endDate) {
|
|
275
|
+
const data = normalizeRows(await client.call("quote.day-kline", { securityList: codes, startDate, endDate, fieldList: ["securityCode", "tradeDate", "close"], limit: 10_000 }));
|
|
276
|
+
const list = Array.isArray(data) ? data : (data?.list ?? []);
|
|
277
|
+
const out = new Map();
|
|
278
|
+
for (const k of list) {
|
|
279
|
+
if (typeof k.securityCode !== "string" || typeof k.tradeDate !== "string" || typeof k.close !== "number")
|
|
280
|
+
continue;
|
|
281
|
+
const series = out.get(k.securityCode) ?? [];
|
|
282
|
+
series.push([k.tradeDate, k.close]);
|
|
283
|
+
out.set(k.securityCode, series);
|
|
284
|
+
}
|
|
285
|
+
for (const series of out.values())
|
|
286
|
+
series.sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
|
287
|
+
return out;
|
|
288
|
+
},
|
|
289
|
+
async quotes(codes) {
|
|
290
|
+
const data = normalizeRows(await client.call("quote.realtime", { securityList: codes }));
|
|
291
|
+
const list = Array.isArray(data) ? data : (data?.list ?? []);
|
|
292
|
+
const out = new Map();
|
|
293
|
+
for (const q of list) {
|
|
294
|
+
const prices = [q.preClose, q.latestPrice].filter((p) => typeof p === "number");
|
|
295
|
+
if (typeof q.securityCode === "string" && prices.length)
|
|
296
|
+
out.set(q.securityCode, prices);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isVersionNewer } from "./args.js";
|
|
5
|
+
import { PACKAGE_NAME, REGISTRY } from "./updateCheck.js";
|
|
6
|
+
export const SKILL_HOMES = [[".claude", "skills"], [".codex", "skills"], [".openclaw", "workspace", "skills"], [".hermes", "skills"]];
|
|
7
|
+
const SKILL_DIR = "gangtise-openapi";
|
|
8
|
+
const runNpm = (args, inherit) => {
|
|
9
|
+
const options = { encoding: "utf8", stdio: inherit ? "inherit" : "pipe" };
|
|
10
|
+
const result = process.platform === "win32" ? spawnSync(["npm", ...args].join(" "), { ...options, shell: true }) : spawnSync("npm", args, options);
|
|
11
|
+
return { status: result.status, stdout: result.stdout ?? "" };
|
|
12
|
+
};
|
|
13
|
+
function isSymlink(file) {
|
|
14
|
+
try {
|
|
15
|
+
return fs.lstatSync(file).isSymbolicLink();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function samePath(a, b) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function installVersion(version, packageRoot, npm = runNpm) {
|
|
30
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version))
|
|
31
|
+
throw new Error(`The registry answered an unexpected version (${JSON.stringify(version)}); nothing was installed.`);
|
|
32
|
+
const root = npm(["root", "-g"], false);
|
|
33
|
+
const globalRoot = root.status === 0 ? root.stdout.trim() : "";
|
|
34
|
+
if (!globalRoot)
|
|
35
|
+
throw new Error("Could not run npm, which gangtise update uses to install. Install Node.js with npm, or update gangtise the way it was installed.");
|
|
36
|
+
const installed = path.join(globalRoot, PACKAGE_NAME);
|
|
37
|
+
if (!samePath(installed, packageRoot)) {
|
|
38
|
+
throw new Error(`This gangtise runs from ${packageRoot}, which is not npm's global install (${globalRoot}). Update it the way it was installed — in a clone: git pull && npm install && npm run build.`);
|
|
39
|
+
}
|
|
40
|
+
if (isSymlink(installed)) {
|
|
41
|
+
throw new Error(`This gangtise is a checkout linked into npm's global install (${installed} → ${packageRoot}, npm link). Update the checkout instead: git pull && npm install && npm run build.`);
|
|
42
|
+
}
|
|
43
|
+
const spec = `${PACKAGE_NAME}@${version}`;
|
|
44
|
+
const install = npm(["install", "-g", spec, `--registry=${REGISTRY}`], true);
|
|
45
|
+
if (install.status !== 0) {
|
|
46
|
+
throw new Error(`npm install exited with ${install.status ?? "a signal"}. On a permission error (EACCES), run: sudo npm install -g ${spec} --registry=${REGISTRY} — then gangtise update again to refresh the Agent Skill copies.`);
|
|
47
|
+
}
|
|
48
|
+
const landed = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
|
|
49
|
+
if (landed !== version)
|
|
50
|
+
throw new Error(`npm reported success, but ${packageRoot} still holds ${landed}.`);
|
|
51
|
+
}
|
|
52
|
+
function skillVersion(dir) {
|
|
53
|
+
try {
|
|
54
|
+
return /^version:\s*["']?([^"'\n]+)/m.exec(fs.readFileSync(path.join(dir, "SKILL.md"), "utf8"))?.[1];
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function refreshSkillCopies(packageRoot, home) {
|
|
61
|
+
const source = path.join(packageRoot, SKILL_DIR);
|
|
62
|
+
const version = skillVersion(source);
|
|
63
|
+
if (!version)
|
|
64
|
+
return [];
|
|
65
|
+
const replaced = [];
|
|
66
|
+
for (const parts of SKILL_HOMES) {
|
|
67
|
+
const target = path.join(home, ...parts, SKILL_DIR);
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = fs.lstatSync(target);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!stat.isDirectory())
|
|
76
|
+
continue;
|
|
77
|
+
const current = skillVersion(target);
|
|
78
|
+
if (current !== undefined && !isVersionNewer(version, current))
|
|
79
|
+
continue;
|
|
80
|
+
const staging = `${target}.update-${process.pid}`;
|
|
81
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
82
|
+
try {
|
|
83
|
+
fs.cpSync(source, staging, { recursive: true });
|
|
84
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
85
|
+
fs.renameSync(staging, target);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
89
|
+
throw new Error(`Could not replace the Agent Skill copy at ${target} (${error.message}). Copy ${source} there again.`);
|
|
90
|
+
}
|
|
91
|
+
replaced.push(`Agent Skill updated: ${target} (${current ?? "no version"} → ${version})`);
|
|
92
|
+
}
|
|
93
|
+
return replaced;
|
|
94
|
+
}
|
|
@@ -1,36 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { isVersionNewer } from "./args.js";
|
|
5
6
|
import { writeFileAtomicSync } from "./output.js";
|
|
6
7
|
export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
export const REFRESH_RETRY_MS = 60 * 60 * 1000;
|
|
7
9
|
export const DEFAULT_UPDATE_CHECK_PATH = path.join(os.homedir(), ".config", "gangtise", "update-check.json");
|
|
8
|
-
|
|
10
|
+
export const REGISTRY = "https://registry.npmjs.org";
|
|
11
|
+
export const PACKAGE_NAME = "gangtise-openapi-cli";
|
|
12
|
+
function readFile(cachePath) {
|
|
13
|
+
try {
|
|
14
|
+
const kept = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
15
|
+
if (kept && typeof kept === "object")
|
|
16
|
+
return kept;
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
const answerIn = (kept) => typeof kept.checkedAt === "number" && typeof kept.latest === "string" ? kept : undefined;
|
|
22
|
+
const isFresh = (kept, now) => now - kept.checkedAt >= 0 && now - kept.checkedAt < UPDATE_CHECK_TTL_MS;
|
|
23
|
+
function keep(cachePath, kept) {
|
|
9
24
|
try {
|
|
10
25
|
mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
11
|
-
writeFileAtomicSync(cachePath, JSON.stringify(
|
|
26
|
+
writeFileAtomicSync(cachePath, JSON.stringify(kept), { suffix: "tmp" });
|
|
12
27
|
}
|
|
13
28
|
catch {
|
|
14
29
|
}
|
|
15
30
|
}
|
|
16
|
-
export async function latestVersion(cachePath = DEFAULT_UPDATE_CHECK_PATH, timeoutMs = 2000, now = Date.now()) {
|
|
31
|
+
export async function latestVersion(cachePath = DEFAULT_UPDATE_CHECK_PATH, timeoutMs = 2000, now = Date.now(), fresh = false) {
|
|
17
32
|
const cacheable = path.isAbsolute(cachePath);
|
|
18
|
-
if (cacheable) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (typeof cached.latest === "string" && age >= 0 && age < UPDATE_CHECK_TTL_MS)
|
|
23
|
-
return cached.latest;
|
|
24
|
-
}
|
|
25
|
-
catch { }
|
|
33
|
+
if (cacheable && !fresh) {
|
|
34
|
+
const kept = answerIn(readFile(cachePath));
|
|
35
|
+
if (kept && isFresh(kept, now))
|
|
36
|
+
return kept.latest;
|
|
26
37
|
}
|
|
27
38
|
try {
|
|
28
|
-
const response = await fetch(
|
|
39
|
+
const response = await fetch(`${REGISTRY}/${PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
29
40
|
const latest = (await response.json()).version;
|
|
30
41
|
if (typeof latest !== "string")
|
|
31
42
|
return undefined;
|
|
32
43
|
if (cacheable)
|
|
33
|
-
|
|
44
|
+
keep(cachePath, { checkedAt: now, latest });
|
|
34
45
|
return latest;
|
|
35
46
|
}
|
|
36
47
|
catch {
|
|
@@ -40,6 +51,31 @@ export async function latestVersion(cachePath = DEFAULT_UPDATE_CHECK_PATH, timeo
|
|
|
40
51
|
export async function checkForUpdate(current) {
|
|
41
52
|
const latest = await latestVersion();
|
|
42
53
|
if (latest && isVersionNewer(latest, current)) {
|
|
43
|
-
process.stderr.write(`Update available: ${current} → ${latest}\nRun:
|
|
54
|
+
process.stderr.write(`Update available: ${current} → ${latest}\nRun: gangtise update\n`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function refreshInBackground(cachePath) {
|
|
58
|
+
try {
|
|
59
|
+
const script = `import(${JSON.stringify(import.meta.url)}).then((m) => m.latestVersion(${JSON.stringify(cachePath)}, 10000, Date.now(), true))`;
|
|
60
|
+
const child = spawn(process.execPath, ["-e", script], { detached: true, stdio: "ignore", windowsHide: true });
|
|
61
|
+
child.on("error", () => { });
|
|
62
|
+
child.unref();
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
export function noticeUpdate(current, cachePath = DEFAULT_UPDATE_CHECK_PATH, now = Date.now(), refresh = refreshInBackground) {
|
|
67
|
+
if (!path.isAbsolute(cachePath))
|
|
68
|
+
return;
|
|
69
|
+
const file = readFile(cachePath);
|
|
70
|
+
const kept = answerIn(file);
|
|
71
|
+
const started = file.refreshStartedAt;
|
|
72
|
+
if ((!kept || !isFresh(kept, now)) && !(typeof started === "number" && now - started >= 0 && now - started < REFRESH_RETRY_MS)) {
|
|
73
|
+
file.refreshStartedAt = now;
|
|
74
|
+
keep(cachePath, file);
|
|
75
|
+
refresh(cachePath);
|
|
44
76
|
}
|
|
77
|
+
if (!kept || kept.notifiedAt !== undefined || !isVersionNewer(kept.latest, current))
|
|
78
|
+
return;
|
|
79
|
+
process.stderr.write(`Update available: ${current} → ${kept.latest}. Run: gangtise update\n`);
|
|
80
|
+
keep(cachePath, { ...file, notifiedAt: now });
|
|
45
81
|
}
|
package/dist/src/version.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
function
|
|
4
|
+
function findPackage() {
|
|
5
5
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
for (;;) {
|
|
7
7
|
const file = path.join(dir, "package.json");
|
|
8
8
|
if (fs.existsSync(file)) {
|
|
9
9
|
const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
10
10
|
if (pkg.name === "gangtise-openapi-cli" && pkg.version)
|
|
11
|
-
return pkg.version;
|
|
11
|
+
return { root: dir, version: pkg.version };
|
|
12
12
|
}
|
|
13
13
|
const parent = path.dirname(dir);
|
|
14
14
|
if (parent === dir)
|
|
@@ -16,4 +16,6 @@ function packageVersion() {
|
|
|
16
16
|
dir = parent;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
const found = findPackage();
|
|
20
|
+
export const PACKAGE_ROOT = found.root;
|
|
21
|
+
export const CLI_VERSION = found.version;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gangtise-openapi
|
|
3
|
-
version: "0.
|
|
3
|
+
version: "0.49.0"
|
|
4
4
|
description: |-
|
|
5
5
|
通过 gangtise CLI 直接调用 Gangtise OpenAPI,拉取投研原始数据、批量导出、下载文件、调用 AI 能力。
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ description: |-
|
|
|
10
10
|
|
|
11
11
|
**不适用**:不脱离 OpenAPI 自行撰写研报、编造投研结论或做自由问答——观点总结、多空 PK 等 AI 产物本 skill 只经由 Gangtise 平台 AI 接口获取,不自行生成
|
|
12
12
|
|
|
13
|
-
**前置**:依赖 gangtise CLI,未安装时提示用户 `npm install -g gangtise-openapi-cli`
|
|
13
|
+
**前置**:依赖 gangtise CLI,未安装时提示用户 `npm install -g gangtise-openapi-cli`;升级用 `gangtise update`
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# Gangtise OpenAPI CLI
|
|
@@ -252,7 +252,7 @@ vault.stock-pool.create
|
|
|
252
252
|
| 热点话题 / 早午晚报 | `ai hot-topic` |
|
|
253
253
|
| 管理层讨论(财报) | `ai management-discuss-announcement` |
|
|
254
254
|
| 管理层讨论(业绩会) | `ai management-discuss-earnings-call` |
|
|
255
|
-
| 管理层情绪 / 业绩会问答是否回避 · 乐观 · 确定 / 情绪较上一场的变化 | `ai management-sentiment-indicator`(三维评分,5/条)/ `ai management-sentiment-conclusion`(结论文本与较上一场的对比,5/条);逐条问答与判断依据 → `ai management-sentiment-detail --source-id <source.sourceId>`(10
|
|
255
|
+
| 管理层情绪 / 业绩会问答是否回避 · 乐观 · 确定 / 情绪较上一场的变化 | `ai management-sentiment-indicator`(三维评分,5/条)/ `ai management-sentiment-conclusion`(结论文本与较上一场的对比,5/条);逐条问答与判断依据 → `ai management-sentiment-detail --source-id <source.sourceId>`(10/次) |
|
|
256
256
|
| 日 K(历史,A 股 / 港股 / 美股 / 沪深 ETF / 各类指数含全球指数,可混查) | `quote day-kline` |
|
|
257
257
|
| 沪深京指数日 K / 要指数名称 | `quote day-kline` 逐个传指数代码(**没有一次拿全部指数的写法**,`index-day-kline --security all` 会被 CLI 拒绝);指数名称用 `reference securities-search --keyword <指数代码> --category index`(返回 `gtsName`) |
|
|
258
258
|
| ~~港股 / 美股日 K~~ | ⚠️ 已弃用,用 `quote day-kline`(`day-kline-hk` / `day-kline-us` 仍可调但不校验代码) |
|
|
@@ -284,13 +284,13 @@ vault.stock-pool.create
|
|
|
284
284
|
| 基金经理画像(按人名) | `fund manager-info --manager <姓名>`(精确匹配,同名全返回,用 `currentCompany` 区分) |
|
|
285
285
|
| 某只基金的历任 / 现任经理 | `fund manager-history`(`endDate` 为 `null` = 现任) |
|
|
286
286
|
| 基金规模 / 份额变动 / 申赎份额 | `fund asset-size`(报告期口径);ETF 逐日份额与规模 → `fund etf-share-change` |
|
|
287
|
-
| 基金持有人结构 / 机构占比 / 前十大持有人 | `fund holder-structure
|
|
287
|
+
| 基金持有人结构 / 机构占比 / 前十大持有人 | `fund holder-structure`、`fund top10-holders`(仅上市基金) |
|
|
288
288
|
| 基金资产配置 / 股票仓位 / 债券仓位 | `fund asset-allocation` |
|
|
289
289
|
| 基金重仓股 / 全部持股 / 持仓明细 | `fund stock-portfolio`(`--position-type all` 仅中报、年报有;只返回股票简称、无代码) |
|
|
290
290
|
| 基金行业配置 / 行业分布 | `fund industry-allocation`(申万 / 中信一级二选一) |
|
|
291
291
|
| 基金持债 / 券种分布 | `fund bond-portfolio` / `fund bond-type-allocation` |
|
|
292
292
|
| FOF 持有的基金 / 持有基金类型分布 | `fund fund-portfolio` / `fund fund-type-allocation` |
|
|
293
|
-
| ETF 申购赎回清单 / 成分股 / 现金差额 | `fund etf-pcf-components` / `fund etf-pcf-header
|
|
293
|
+
| ETF 申购赎回清单 / 成分股 / 现金差额 | `fund etf-pcf-components` / `fund etf-pcf-header`(**只有最新一份清单**,无历史;成分代码 `componentCode` 由 CLI 按名称匹配补上,现金行等匹配不到的为 `null`) |
|
|
294
294
|
| 会议线索 / 会议要点 / 核心结论信息流 / 今天有什么会 | `insight highlight list`(🔴 **5 积分/条**,必须带 `--size`;`content` 是 HTML 片段) |
|
|
295
295
|
| 联网搜索 / 查公开信息 / 政策原文 / 传闻核实 | `tool web-search`(1 积分/次;`--site` 定向站点、`--min-tier` 收信源、`--include-content` 取正文〔此时 `--size` ≤5〕) |
|
|
296
296
|
| 云盘文件 | `vault drive-list / drive-download` |
|
|
@@ -430,7 +430,7 @@ gangtise reference securities-search --keyword <公司名> --category stock --to
|
|
|
430
430
|
| `0000001008` / `999002` | token 失效 | CLI 自动换 token(必要时重新登录)后重发,无 AK/SK 时提示重新登录 |
|
|
431
431
|
| `100006` | 查询/下载数量超限 | 缩短日期范围或调小 `--size`/`--limit`;`fund` 没有这两个参数,减少 `--security` 只数或缩短区间 |
|
|
432
432
|
|
|
433
|
-
其他场景:CLI 未安装 → `npm install -g gangtise-openapi-cli
|
|
433
|
+
其他场景:CLI 未安装 → `npm install -g gangtise-openapi-cli`;命令或选项报不存在、`gangtise --version` 低于本文件头部的 `version` → 跑 `gangtise update --check`,有新版就请用户运行 `gangtise update`(会一并更新本 skill 的副本);报 `update` 命令不存在(0.49.0 之前的版本)则请用户运行 `npm install -g gangtise-openapi-cli@latest`。别自行安装;空结果 → 扩大时间范围 / 换关键词 / 去掉部分筛选;模糊公司名匹配多只 → 列出让用户选;下载路径冲突 → 询问覆盖。
|
|
434
434
|
|
|
435
435
|
## 详细参数
|
|
436
436
|
|
|
@@ -186,7 +186,7 @@ gangtise ai management-sentiment-detail --source-id <id> [--source-type <typ
|
|
|
186
186
|
- `--security`:可重复,只收具体代码(`aShares` 这类市场关键词报 `120001`),任一代码非法整批报 `120001`。**不传 = 全部证券**——按条计费,省略 `--size` 时 CLI 先取 1 条拿到总数估算,超过 1000 积分报错退出,确认后加 `--yes`
|
|
187
187
|
- `--start-time` / `--end-time`:按发布时间筛,写 `yyyy-MM-dd`、`yyyy-MM-dd HH:mm:ss` 或 10 / 13 位时间戳——**写日期时间就要带秒**(`HH:mm` 报 `110001`);只写日期的 `--end-time` 含当天整天
|
|
188
188
|
- `--from` / `--size`:自动翻页(单页 100);省略 `--size` 拉全量
|
|
189
|
-
-
|
|
189
|
+
- `publishTime`(含 `conclusion` 里的 `previousSource.publishTime`)是北京时间,与 `--start-time` / `--end-time` 的筛选同一口径(北京时间 20:30 开的业绩会返回 `20:30:00`,`--start-time "2026-10-23 20:00:00"` 取得到这一场)
|
|
190
190
|
|
|
191
191
|
**`indicator`**:整场评分 `sentimentResponsiveness` / `sentimentPolarity` / `sentimentConviction`(0–5,两位小数)、有效问答数 `qaCount`、回避 / 风险 / 低确定度三个占比 `qaEvasionRatio` / `qaRiskRatio` / `qaUncertaintyRatio`(**百分数**:`7.14` 即 7.14%,分母是 `qaCount`),以及按问题类型拆分的 `questionCategoryList`。
|
|
192
192
|
|
|
@@ -108,7 +108,7 @@ gangtise fund holder-structure --security <code>... [--start-date <d>] [--end-da
|
|
|
108
108
|
|
|
109
109
|
持有人户数、户均份额,机构 / 个人 / 员工持有份额(份)与占比(%)。只有半年报与年报披露,季报期没有行。
|
|
110
110
|
|
|
111
|
-
-
|
|
111
|
+
- `holderCount` 为整数(如 `2219140`)
|
|
112
112
|
- 占比字段可能为 `null`
|
|
113
113
|
|
|
114
114
|
## `top10-holders` 前十大持有人
|
|
@@ -165,7 +165,11 @@ gangtise fund etf-share-change --security <code>... [--start-date <d>] [--end
|
|
|
165
165
|
|
|
166
166
|
- **`etf-pcf-header` / `etf-pcf-components` 只返回最新一份申购赎回清单**,无日期参数,查不了历史清单。`tradeDate` 是清单对应的交易日(清单盘前发布,可能就是当天)
|
|
167
167
|
- **`etf-pcf-header`**:申赎允许情况(`允许申购·允许赎回` 这种 `·` 分隔的组合)、最小申赎单位(份)及其净值(元)、单日申购 / 赎回上限(份,无上限为 `null`)、现金差额与预估现金(元,可为负)、现金替代比例上限(%)、`componentCount` 成分记录数
|
|
168
|
-
- **`etf-pcf-components`**:每个成分一行,按 `serialNumber` 排序:成分代码 `componentCode`
|
|
168
|
+
- **`etf-pcf-components`**:每个成分一行,按 `serialNumber` 排序:成分代码 `componentCode`、成分名称 `componentName`、收盘价、涨跌幅、市值占比(%)、申购数量(股)、`cashSubstituteFlag`(`允许` / `必须`,中文)、申购 / 赎回现金替代溢价比例(%)、固定替代金额与申购 / 赎回替代金额(元)。宽基 ETF 动辄数百行,多只一起查注意 10000 行上限
|
|
169
|
+
- 🔴 **`componentCode` 是 CLI 按名称匹配补上的**(接口只返回名称),带市场后缀(如 `600519.SH` / `00941.HK` / `NVDA.O` / 国债 `019848.SH`)。匹配方式:按名称找证券(先查证券列表,没有再搜索),再用该行的 `close` 核对候选证券的最新价或昨收;核对不上的(清单价格滞后于行情、停牌等),改按清单价格所属那天的收盘价核对(A 股与港股各自定日期)——A+H 型 ETF 里同名的 A 股与 H 股两行据此分开;价格定不了时(债券、停牌、除权除息当天、行情查询失败):成分都在同一市场的 ETF 取该市场里名称完全一致的那只,同市场多只时取与 ETF 同一交易所的;**A+H 等跨市场的 ETF 里,价格定不了的行一律为 `null`**,不按名称猜
|
|
170
|
+
- **匹配不到的为 `null`**:现金行(如 `申赎现金`)、商品(如 `黄金9999`)、交易所简称与证券名称对不上的(如港股 `港交所`、`中海油`)。stderr 会说明匹配了多少行、列出为 `null` 的行
|
|
171
|
+
- ⚠️ 成分的 `close` 是清单给出的价格,日期可能早于 `tradeDate` 的前一交易日,一天之内还会更新,同一清单里 A 股与港股的日期也可能不同。要用价格计算,请以 `quote day-kline` / `quote realtime` 为准
|
|
172
|
+
- 额外请求都免费:下载一次 A 股 / 港股 / 美股证券列表(3 次请求)在本地按名称匹配,列表里没有的名称(债券、被截断的简称、现金行等)才逐个搜索,另加一两次实时行情查询;有实时价格核对不上的行时(清单价格滞后、停牌、除权除息等),再加一次日 K 查询。只要接口原始返回时加 `--no-component-code`(不发这些请求,行里没有 `componentCode`)
|
|
169
173
|
- **`etf-share-change`**:逐交易日一行,份额(万份)、规模(万元)及较上期变动,`sharesChangeRate` 为份额变化率(%)。按日期倒序
|
|
170
174
|
|
|
171
175
|
---
|
|
@@ -34,7 +34,7 @@ gangtise indicator search --keyword <text> [--limit <n>]
|
|
|
34
34
|
- **市场范围按指标判断**:`scopeList` 会返回实际覆盖范围,且指标之间不同;不能笼统写成每个指标都覆盖 A / 港 / 美股。覆盖面在持续扩,别把抽查结论当常量:例如 `qte_mkt_cptl`/`shr_tot` 覆盖 A/港/美股,`finc_pe_ttm` / `finc_pb_mrq` 覆盖 A/港股,`is_op_rev_ttm`/`is_shnp_ttm` 覆盖 A/港股。目标列表含任一 scope 外证券时,本批 EDE 校验不通过,应回退专用接口
|
|
35
35
|
- **`scopeList[].usageRestriction`**:该指标在具体接口上的限制,`null` = 无限制。⚠️ **它是提示不是硬约束**——标着「不支持指标时间序列接口」的 `qte_vol_intvl` 调时序照样返数据、不会被拦下。当成「口径可能不对、结果别当真」看,别指望它会报错
|
|
36
36
|
- ⚠️ **`scopeList` 是声明不是保证**:它可能**超前于数据**(声明覆盖但该市场暂无数据),也可能**滞后于数据**(已有数据尚未声明)。**「某某仍无数据」这类否定结论过期得和正面结论一样快**,照着它走会白白拒掉一个现在能跑的查询,而且不报错。**判断成本很低**:无数据会保留行列、给一个占位单元格,抽查一行就能看出覆盖情况;code 写错则直接报 `100003`,两种情形不会混淆。占位值统一是 `null`(详见下方「缺数据 vs 代码写错」)
|
|
37
|
-
- 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE):用 `AAPL.O`,`AAPL.US` 取不到数据
|
|
37
|
+
- 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE) / `.A`(NYSE American,原 AMEX):用 `AAPL.O`,`AAPL.US` 取不到数据
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
gangtise indicator search --keyword 营业收入 --limit 10 --format json # 做语义 + scopeList + parameterList 三项校验
|
|
@@ -49,7 +49,7 @@ gangtise indicator cross-section --indicator <code> [--indicator <code2>] \
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
- `--indicator`(**至少 1 个**):指标编码,来自 `search`,可重复传多个
|
|
52
|
-
- `--security`(**至少 1 个**):证券代码,如 `600519.SH`(A股)/ `09992.HK`(港股)/ `AAPL.O`(美股,用 `.O`/`.N` 后缀,非 `.US`),可重复传多个。**也接受板块 ID**(`reference sector-search` 返回的 10 位 `sectorId`,如 `1000000287` 中信白酒 → 展开成它的全部成分股),代码与板块可混传,服务端取并集去重。⚠️ 中信行业码那类 9 位 ID(`100800109`)**不是** `sectorId`,传进去返 0 只
|
|
52
|
+
- `--security`(**至少 1 个**):证券代码,如 `600519.SH`(A股)/ `09992.HK`(港股)/ `AAPL.O`(美股,用 `.O`/`.N`/`.A` 后缀,非 `.US`),可重复传多个。**也接受板块 ID**(`reference sector-search` 返回的 10 位 `sectorId`,如 `1000000287` 中信白酒 → 展开成它的全部成分股),代码与板块可混传,服务端取并集去重。⚠️ 中信行业码那类 9 位 ID(`100800109`)**不是** `sectorId`,传进去返 0 只
|
|
53
53
|
- `--date`(**必选**):数据日期 `yyyy-MM-dd`。**CLI 把它下发为每个指标各自的 `tradeDate`**(接口没有根级 date)。日期语义按指标分两类——财务报表指标=报告期末(可为非交易日;**⚠️ `_ttm` 后缀整族例外,走交易日,见下方「日期路由」**)、`finc_pe_ttm` / `finc_pb_mrq` 等日频估值=交易日(详见下方「日期路由」)
|
|
54
54
|
- `--date` 必填:绝大多数指标必填 `tradeDate`,漏传服务端报 `100001` 并点名缺哪个参数,所以 CLI 要求带上、下发给每个指标。(`screener` 的 `--date` 也必填,同理)
|
|
55
55
|
- 🔴 **判据一句话:该指标的 `parameterList` 里有 `tradeDate` 就不用管;没有,`--date` 下发的 `tradeDate` 就可能被拒。** 按这条走,四种情形四种写法(`parameterList` 从 `indicator search --keyword <code> --format json` 读,别按 code 前缀推断):
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
`partial: true`,保留已取到的部分;stderr 有 warning,`--format json` 才看得见标记,table/csv/jsonl 只有数据行、看不出问题。**拿到 3 就必须告知用户缺了哪段,不能当成功静默继续。**
|
|
77
77
|
|
|
78
78
|
- 触发场景:翻页 / K 线分片有页失败、服务端返回行数与 `total` 矛盾(提前短页)、`total` 撞服务端上限(`totalCapped`:`total` 是封顶值而非真实条数,或正好等于偏移窗口、窗口外的行取不到也数不到)、`quote` 系带 `--field` 时请求了但服务端没回的列(`missingFields`)。附带定位字段:页失败 `failedPages`;分片失败 `failedShards`、分片撞行数上限 `truncatedShards`(均带日期区间可缩窗补拉)、只有部分分片返回的列 `droppedColumns`(合并结果的列集取自第一个有数据的分片,多出来的列放不下,需要就缩小日期区间单独拉);缺列 `missingFields`(字段名写错或已下线,按 `references/fields.md` 核对);多证券分批请求里撞行数上限的证券 `truncatedSecurities`;翻页时同一行在相邻两页各出现一次 `duplicateRows`(列表按时间这类可重复的字段排序,同一时刻的一组数据跨在页边界上时,重复了几行也就漏了几行;重复行已去掉,缩短时间范围重拉可补齐);同一 ID 在后面的页以不同内容再次出现 `changedRows`(翻页期间列表有变动、也可能漏了行;两版都保留,按 ID 去重只留一版,重拉即可;重拉后仍在同一处出现,说明该列表同一 ID 对应多条记录,按 ID 去重前先核对)。EDE 的 `omittedIndicators` / `omittedSecurities`:服务端对写错的 code 直接报 `100003`(退出 1),所以这两个标记很少出现
|
|
79
|
-
- **EDE 代码写错是退出码 1 + `100003`,msg 里直接点名是哪个 code**(美股后缀用 `.O`/`.N`,不是 `.US`),不用从退出码 3 反推。真的没覆盖是占位单元格(统一 `null`)+ 退出码 0
|
|
79
|
+
- **EDE 代码写错是退出码 1 + `100003`,msg 里直接点名是哪个 code**(美股后缀用 `.O`/`.N`/`.A`,不是 `.US`),不用从退出码 3 反推。真的没覆盖是占位单元格(统一 `null`)+ 退出码 0
|
|
80
80
|
- **`screener` 的缺列按表达式的布尔结构判**:把缺列的变量当作「无法求值」,看整个表达式**是否还有一条能成立的分支**——`A && B` 要两边都可求值,`A || B` 只要一边。**一条分支都不剩 → 退出码 1、不输出任何结果**(返回的行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过);**还有分支可求值,或缺的只是未参与表达式的辅助变量 → `partial` + 退出码 3**。所以 `F1 || F2` 缺 F1 是降级,缺两个则是致命;`F1 && (F2 || F3)` 缺 F1 仍是致命。⚠️ **前提是服务端返回了命中行**——零命中时走「nothing matched」+ **退出码 0**。**空集别急着当成「条件成立但无标的符合」**:指标码写错会直接报 `100003`(退出 1),剩下**三种成因产生逐字相同的输出**——① 真的没有标的满足条件;② **日期没落在报告期末**(报告期类指标此时整批 `null`);③ **该指标不覆盖这批证券**(拿 A 股专属指标查港美股,如 `mgn_*` 融资融券系列)。②③ 都要靠单查一行确认,不能只改日期
|
|
81
81
|
|
|
82
82
|
## 退出码 4:数据写全了,但文件被另一次导出替换
|
|
@@ -4,7 +4,7 @@ CLI 自动处理 envelope:`{code, msg, data}` 信封会按 `code === "000000"`
|
|
|
4
4
|
|
|
5
5
|
> 例外:`indicator`(EDE)四个接口(`search` / `cross-section` / `time-series` / `screener`)成功时**双层信封**(`data` 里再裹一层 `{code, status, data}`)。内层字段名为 `securityCodeList` / `securityNameList` / `indicatorList[{code,name,dataType}]`(screener 另带 `field`),`values` 是 2D 矩阵:**截面与 screener 为 `[证券][指标]`**、时序为 `[序列][日期]`。
|
|
6
6
|
>
|
|
7
|
-
> **缺数据 vs 代码写错**:无数据 / 无覆盖一律保留行列并给占位单元格——**占位值统一为 `null`**;⚠️ 报告期类指标(`is_*`)的时序只有报告期末那几行是真值,其余全是 `null`,详见 `commands/indicator.md`。**代码写错则直接报 `100003` 并点名**(指标 code 拼错 →「指标 xxx 不存在」;证券 code 后缀错 →「xxx 不是有效证券或者板块ID」,美股是 `.O`/`.N` 不是 `.US`),**无论同批有没有正确的 code 都会报**。CLI 的 `indicator` 子命令已二次解包、拍平成宽表;服务端没有返回请求里的某个指标或证券时标 `partial` + `omittedIndicators` / `omittedSecurities`、退出码 3(写错的 code 会直接报 `100003`,所以这种情况很少出现)。**`screener` 例外**:把缺列的变量当作无法求值,按表达式的**布尔结构**判断是否还有分支能成立(`A && B` 要两边、`A || B` 只要一边)。一条都不剩 → **退出码 1 且不输出**(那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过);仍有分支可求值、或缺的只是输出用的辅助变量 → `partial` + 退出码 3。直接 `raw call indicator.*` 只会剥外层,内层需自行处理。
|
|
7
|
+
> **缺数据 vs 代码写错**:无数据 / 无覆盖一律保留行列并给占位单元格——**占位值统一为 `null`**;⚠️ 报告期类指标(`is_*`)的时序只有报告期末那几行是真值,其余全是 `null`,详见 `commands/indicator.md`。**代码写错则直接报 `100003` 并点名**(指标 code 拼错 →「指标 xxx 不存在」;证券 code 后缀错 →「xxx 不是有效证券或者板块ID」,美股是 `.O`/`.N`/`.A` 不是 `.US`),**无论同批有没有正确的 code 都会报**。CLI 的 `indicator` 子命令已二次解包、拍平成宽表;服务端没有返回请求里的某个指标或证券时标 `partial` + `omittedIndicators` / `omittedSecurities`、退出码 3(写错的 code 会直接报 `100003`,所以这种情况很少出现)。**`screener` 例外**:把缺列的变量当作无法求值,按表达式的**布尔结构**判断是否还有分支能成立(`A && B` 要两边、`A || B` 只要一边)。一条都不剩 → **退出码 1 且不输出**(那些行以「通过了该条件」的名义呈现,而条件根本无法证明被执行过);仍有分支可求值、或缺的只是输出用的辅助变量 → `partial` + 退出码 3。直接 `raw call indicator.*` 只会剥外层,内层需自行处理。
|
|
8
8
|
|
|
9
9
|
## 通用模式(5 类)
|
|
10
10
|
|
|
@@ -27,7 +27,7 @@ CLI 自动处理 envelope:`{code, msg, data}` 信封会按 `code === "000000"`
|
|
|
27
27
|
| insight summary download | 文件路径(stdout) | — |
|
|
28
28
|
| insight pamirs-summary list | `{list, total}` | `list[].summaryId` / `list[].title` / `list[].brief` / `list[].summaryTime`(纪要生成时间)/ `list[].publishTime`(发布时间)/ `list[].securityList[]{securityCode, securityName}` / `list[].researchAreaList[]{researchAreaId, researchAreaName}` / `list[].conceptList[]{conceptId, conceptName}` / `list[].categoryList` / `list[].marketList`。⚠️ `conceptList` / `categoryList` / `marketList` 三个标签字段**稀疏,且是否有值随记录和查法而变**:不带筛选时经常整条为空,用 `--category` 或 `--market` 任一过滤时回填率明显更高(这两个字段绑定,一起有或一起没有)。所以**别拉全量再本地分组**(会漏记录),也**别据此断言某条记录没有该属性**(为空只说明这次没回填)。详见 `commands/insight.md` |
|
|
29
29
|
| insight pamirs-summary download | 文件路径(stdout) | — |
|
|
30
|
-
| insight roadshow / site-visit / strategy / forum list | `{list, total}` |
|
|
30
|
+
| insight roadshow / site-visit / strategy / forum list | `{list, total}` | 各自的 ID:`list[].roadshowId` / `siteVisitId` / `strategyMeetingId` / `forumId`;`list[].title` / `list[].startTime` / `list[].endTime` / `list[].publishTime` / `list[].location`;路演、调研、策略会另有 `list[].institutionList[]{institutionId, institutionName}`(论坛没有) |
|
|
31
31
|
| insight research list | `{list, total}` | `list[].reportId` / `list[].title` / `list[].brief`(全文摘要) / `list[].publishTime` / `list[].publisher.brokerName` / `list[].securityList[].rating` |
|
|
32
32
|
| insight research download | 文件路径(stdout) | — |
|
|
33
33
|
| insight foreign-report list | `{list, total}` | `list[].reportId` / `list[].title` / `list[].titleTranslate` / `list[].brief` / `list[].briefTranslate`(中译全文摘要) / `list[].publishTime` / `list[].publisher.brokerName` |
|
|
@@ -113,11 +113,11 @@ CLI 自动处理 envelope:`{code, msg, data}` 信封会按 `code === "000000"`
|
|
|
113
113
|
| fund fee-rate | 同上 | `[].fundCode` / `feeType` / `feeCondition`(分档条件,无分档为 `null`)/ `feeRate`(**字符串**,如 `"1.5%"`) |
|
|
114
114
|
| fund manager-info | 同上 | `[].managerName` / `currentCompany` / `careerStartDate` / `workingYears` / `managedFundScale`(亿元)/ `managedFundCount` / `historyFundCount` / `introduction`;同名经理多行 |
|
|
115
115
|
| fund manager-history | 同上 | `[].fundCode` / `managerName` / `startDate` / `endDate`(`null` = 现任)/ `managedDays` / `managedReturn`(%) |
|
|
116
|
-
| fund asset-size / holder-structure / top10-holders | 同上,每报告期一行(`top10-holders` 每期按名次多行) | `[].fundCode` / `reportDate` + 规模(`sharesEnd` / `netAsset`,万份 / 万元)、持有人结构(`holderCount`
|
|
116
|
+
| fund asset-size / holder-structure / top10-holders | 同上,每报告期一行(`top10-holders` 每期按名次多行) | `[].fundCode` / `reportDate` + 规模(`sharesEnd` / `netAsset`,万份 / 万元)、持有人结构(`holderCount` 整数 / `pctInstitution` / `pctIndividual`)、前十大(`serialNumber` / `holderName` / `holdingShares` / `holdingPct`) |
|
|
117
117
|
| fund asset-allocation | 同上,每报告期按资产类型多行 | `[].fundCode` / `reportDate` / `assetLevel` / `assetType` / `assetTypeName` / `holdingValue`(元)/ `pctToTotalAsset` |
|
|
118
118
|
| fund stock-portfolio / bond-portfolio / fund-portfolio | 同上,每报告期按持仓多行 | `[].fundCode` / `reportDate` / `stockName` · `bondName` · `holdingFundName`(**只有简称、无代码**)/ `holdingQuantity` / `holdingValue`(万元)/ `pctToNav`;股票另有 `pctToFloatShares` / `pctToNavChg` |
|
|
119
119
|
| fund industry-allocation / bond-type-allocation / fund-type-allocation | 同上,每报告期按分类多行 | `[].fundCode` / `reportDate` / `industryCode` + `industryName` · `bondTypeCode` + `bondTypeName` · `investTypeCodeLevel1` + `investTypeNameLevel1` / `pctToNav`;行业与基金类型另有 `positionType`(`top` / `all`) |
|
|
120
|
-
| fund etf-pcf-header / etf-pcf-components / etf-share-change | 同上 | 申赎参数(`tradeDate` / `purchaseRedemptionStatus` / `minUnitShares` / `cashBalance` / `componentCount`);成分清单(`serialNumber` / `componentCode` / `componentName` / `pctWeight` / `purchaseQuantity` / `cashSubstituteFlag`);份额变动(`tradeDate` / `shares` / `sharesChange` / `scale`,万份 / 万元) |
|
|
120
|
+
| fund etf-pcf-header / etf-pcf-components / etf-share-change | 同上 | 申赎参数(`tradeDate` / `purchaseRedemptionStatus` / `minUnitShares` / `cashBalance` / `componentCount`);成分清单(`serialNumber` / `componentCode`(CLI 按名称匹配补上,匹配不到为 `null`,见 `commands/fund.md`)/ `componentName` / `pctWeight` / `purchaseQuantity` / `cashSubstituteFlag`);份额变动(`tradeDate` / `shares` / `sharesChange` / `scale`,万份 / 万元) |
|
|
121
121
|
| indicator search | `[{indicatorCode, indicatorName, ...}]`(列表) | `indicatorCode` / `indicatorName` / `description` / `scopeList[].market` / `scopeList[].securityType` / `scopeList[].usageRestriction`(接口限制,`null`=无限制) / `parameterList[].paramKey`(**参数名以此为准**) / `.enumList[].value` / `score` |
|
|
122
122
|
| indicator cross-section | CLI 拍平为宽表 `{list, total}` | `list[].security` / `list[].name` + 每个指标名一列;**单日多指标 × 多证券**,每行一只证券。**没有 `date` 列**(查询日期挂在每个指标的参数上,各列可为不同日期);原始响应的 `values` 为 `[证券][指标]` |
|
|
123
123
|
| indicator time-series | CLI 拍平为宽表 `{list, total}` | `list[].date` + 序列列:单证券时列=各指标、多证券时列=各证券;每行一个日期。原始响应 `values` 仍为 `[序列][日期]` |
|