gangtise-openapi-cli 0.18.0 → 0.20.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 CHANGED
@@ -1,9 +1,59 @@
1
1
  # Gangtise OpenAPI CLI
2
2
 
3
- 一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
3
+ 一个可直接调用 Gangtise OpenAPI 获取全量金融信息的命令行工具,同时提供Agent Skill。
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.20.0 — 2026-06-26
8
+
9
+ **新增接口**
10
+ - `insight announcement-us list` / `download` — 美股公司公告列表与下载(`--security TSLA.O`、`--category`〔分类用 `reference constant-list --category usShareAnnouncementCategory`,美股独立的 `103980xxx` 段〕、`--search-type`、`--rank-type`、下载 `--file-type 1` 原始 PDF / `2` Markdown);自动翻页,单页上限 50
11
+ - `ai stock-summary` — 个股看点(精炼投研总结):`--security` 传具体代码(A股/港股,可重复,单次最多 6000)或市场关键词 `aShares` / `hkStocks` 拉全市场;无看点的证券不返回、不扣分
12
+ - `fundamental income-statement-us` / `balance-sheet-us` / `cash-flow-us` — 美股三大财务报表(参数同其他财报:`--security-code` / `--period` / `--report-type` / `--fiscal-year` / `--field` 等)
13
+ - `reference chiefs-search` — 首席分析师 ID 搜索(`--keyword` 按姓名/机构/团队匹配,`--top` 默认 10);用于 `insight opinion list --chief` 的入参
14
+
15
+ **变更**
16
+ - `insight announcement-hk download` 新增 `--file-type`(`1` 原始(默认)/ `2` Markdown),此前无格式选项
17
+
18
+ **行为变更(注意)**
19
+ - ⚠️ `auth login` / `auth status` 默认脱敏 access token:`--format json` 输出里 `authorization` 与 `cache.accessToken` 显示为 `<redacted>`,仅保留过期时间 / 用户名 / 产品码 / uid 等非敏感字段。**依赖 `auth login` 原始 token 输出的脚本会拿到 `<redacted>`**,需改用 `auth login --show-token` 获取明文。
20
+
21
+ **修复(安全)**
22
+ - `auth status` / `auth login` token 脱敏:按凭证字段名模式匹配(`token`/`key`/`secret`/`password`/`credential`),覆盖 `apiKey`/`privateKey`/`refreshToken` 等任何可能携带的凭证字段
23
+ - 自愈守卫:同时设 `GANGTISE_TOKEN` + AK/SK 时,注入 token 失效后重新登录不再被旧 token 短路,重试改用登录拿到的新 token
24
+
25
+ **修复(数据正确性 / 健壮性)**
26
+ - ⚠️ **CSV 负数不再被破坏**(影响所有 CSV 导出):此前防公式注入会把负数(如跌幅 `-3.5`)加 `'` 前缀变成文本,Excel/pandas 无法参与计算;现仅对非有限数字的可疑串(`=`/`@`/`-1+cmd` 等)加前缀,合法数字原样输出
27
+ - 自动翻页改为 fail-soft:某页遇不可重试错误(限流 `903301` 等)不再丢弃已取的全部数据,返回已取页 + `partial` / `failedPages` 标记,并在首错后停止继续请求(避免撞限流多烧配额)
28
+ - 下载文件名 fallback(服务端 `Content-Disposition`)补清洗:含 `/`、`:` 等字符的文件名不再写到意外路径
29
+ - `ai stock-summary` / `ai knowledge-batch` 缺 `--security` / `--query` 时本地报错,不再发空请求(stock-summary 借此避免被后台当全市场误扣积分)
30
+ - `ai hot-topic` `--no-with-related-securities` / `--no-with-close-reading` 改为显式发 `false`(语义更明确,不依赖"字段缺失=排除"的隐含约定)
31
+
32
+ **修复(indicator 适配 EDE 后台新结构)**
33
+ - `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
34
+
35
+ ### v0.19.0 — 2026-06-24
36
+
37
+ **新增接口(Indicator · 证券级数据指标 EDE)**
38
+ - `indicator search` — 按名称搜索证券级数据指标,返回 `indicatorCode` 及可传参数 `parameterList`(含 `required` 必填标记与枚举);取数前必先 search 拿 code,绝不猜编码
39
+ - `indicator cross-section` — 指标截面数据(多指标 × 多证券,单日快照):`--indicator` / `--security`(均可重复)/ `--date` / `--currency` / `--scale` / `--indicator-param`
40
+ - `indicator time-series` — 指标时间序列(多指标 × 单证券 或 单指标 × 多证券,按区间):另有 `--start-date` / `--end-date` / `--calendar-type`(`ND`/`TD`/`WD`)
41
+ - 复权等指标专属参数用 `--indicator-param "code:key=value"`,参数 key 与取值以 search 的 `parameterList` 为准(行情复权键为 `adjustmentType`:`1` 不复权 / `2` 前复权 / `3` 后复权)
42
+ - 很多指标有必填参数,默认调用会报 `410106`(缺必填参数):N 期统计补 `periodNum`、区间/周期类补 `startDate`、年度/分红类补 `fiscalYear`;`999999` 多为「该证券公司类型/报告期无数据」而非系统故障。详见 `gangtise-openapi/references/commands/indicator.md`
43
+
44
+ **修复**
45
+ - `vault stock-pool-stocks --pool-id <id>` 过滤失效:此前因选项默认值 `["all"]` 泄漏,传具体 pool id 仍返回全部股票池证券;现已修复——传 id 精确过滤,省略则默认全量
46
+ - `auth` 缺凭证报错补充跨 shell(bash/zsh/fish)的 `export` 提示
47
+
48
+ **文档**
49
+ - README / SKILL 补充 indicator 命令组与取数最佳实践;`official-account` 命令文档补全
50
+
51
+ ### v0.18.0 — 2026-06-17
52
+
53
+ **新增接口(Insight · 产业公众号资讯)**
54
+ - `insight official-account list` — 查询公众号资讯列表:支持 `--keyword`(需用数据中的具体词,非整句白话)/ `--account-id`(公众号 ID)/ `--security` / `--category`(文章类型枚举:`news`/`law`/`report`/`view`/`data`/`event`/`meeting`/`notice`/`recruit`/`investEdu`/`brand`/`notes`/`other`)/ `--industry`(`citicIndustry`/`swIndustry` 行业 ID)/ `--search-type`(`1` 标题 / `2` 全文)/ `--rank-type`(`1` 综合 / `2` 时间倒序);返回含模型生成摘要 `summary` 及关联行业/题材/证券列表
55
+ - `insight official-account download --article-id <id>` — 下载公众号文章:`--file-type 1` txt(默认)/ `2` HTML
56
+
7
57
  ### v0.17.0 — 2026-06-15
8
58
 
9
59
  **接口变更(Breaking)**
@@ -28,106 +78,7 @@
28
78
  - `lookup` 仅保留 2 个 API 未覆盖的本地表:`broker-org` / `meeting-org`
29
79
  - 路演/调研/策略会/论坛 list 新增 `--location <id>` 按城市过滤(domesticCity 常量 ID;服务端过滤 v0.17.0 起已生效)
30
80
 
31
- ### v0.15.0 2026-05-29
32
-
33
- **新增接口**
34
- - `alternative concept-info` — 题材指数基本信息:返回题材整体画像(定义 / 投资逻辑 / 行业空间 / 竞争格局 / 催化事件)。按 `--concept-id` 查询,仅返回最新截面数据,不支持历史回溯
35
- - `alternative concept-securities` — 题材指数成分股(题材深度 F8):按分组结构返回当前成分股,每只含是否重点个股 `isKey` 与纳入理由 `inclusionReason`。按 `--concept-id` 查询
36
-
37
- **接口变更**
38
- - `quote index-day-kline` 返回字段新增 `securityName`(指数名称,如"上证指数")
39
-
40
- > `--concept-id` 与主题跟踪 `ai theme-tracking --theme-id` 共用同一套题材 ID 体系,可用 `gangtise lookup theme-id list` 按名称查询(如 机器人 → `121000130`)。
41
-
42
- ### v0.14.4 — 2026-05-29
43
-
44
- **Bug fix(全市场 K 线分片容错)**
45
- - `quote day-kline --security all` 等全市场查询的日期分片改为容错:部分分片失败时返回已成功分片的数据并标记 `partial: true` + `failedShards`(失败的日期区间),同时向 stderr 告警;只有全部分片失败才抛错。此前为 fail-fast,单片失败会让整次查询失败,或在异常路径上被误判为空结果。
46
-
47
- ### v0.14.3 — 2026-05-29
48
-
49
- **性能 / 健壮性**
50
- - 标题缓存按端点封顶(5000 条/端点)并清理过期项,修复 `title-cache.json` 无上限增长(曾达 ~58MB)拖慢启动的问题
51
- - 下载接口遇鉴权失效(`8000014` / `8000015`)自动刷新 token 并重试一次(此前仅普通 JSON 调用具备 token 自愈)
52
- - CLI handler 抽出 `emit` / `withClient` 公共封装去除重复样板;CSV 转义逻辑去重;翻页与 K 线分片统一走 `GANGTISE_PAGE_CONCURRENCY` 并发控制
53
- - 补齐多个 core 模块的单元测试
54
-
55
- ### v0.14.2 — 2026-05-22
56
-
57
- **Bug fix(A 股 / HK 全市场 K 线同源问题)**
58
- - `quote day-kline --security all` 由 2 天/片改为 **1 天/片**(A 股全市场单日约 5500 行)
59
- - `quote day-kline-hk --security all` 由 3 天/片改为 **2 天/片**(港股全市场单日约 2770 行)
60
- - 根治性修复:`callKlineWithSharding` 在 `--security all` 路径上,若用户未显式传 `--limit`,强制写入 `limit: 10000`(API 上限),不再走默认 6000——这样即便分片日数估算偏大,每个 shard 也能拿满 10K 行。用户自己传的 `--limit` 仍然保留生效。
61
-
62
- ### v0.14.1 — 2026-05-22
63
-
64
- **Bug fix**
65
- - `quote day-kline-us --security all` 分片由 2 天/片改为 **1 天/片**。美股全市场单日约 5800 行,原 2 天/片会在第一个 shard 命中默认 `--limit 6000` 上限,导致 shard 内第二日数据被截断到几百行。改 1 天/片后每个 shard 数据完整。
66
-
67
- ### v0.14.0 — 2026-05-22
68
-
69
- **新增接口**
70
- - `quote realtime` — 个股实时行情快照,单接口同时覆盖 A 股 / 港股 / 美股;支持代码混合传入或市场关键字(`aShares` / `hkStocks` / `usStocks`)批量查询全市场
71
- - `quote day-kline-us` — 美股历史日 K 线,数据范围 NYSE / NASDAQ / AMEX;支持 `--security all` 全市场(CLI 自动按 1 天/片切分并发拉取,美股全市场单日约 5800 行)
72
-
73
- **接口变更**
74
- - `quote day-kline` / `quote day-kline-hk` 明确仅返回**历史**日 K 线,不包含盘中实时数据;当日数据入库时间:A 股 ~15:30 / 港股 ~16:30(北京时间)。盘中实时请走 `quote realtime`
75
- - `fundamental valuation-analysis` 返回字段移除 `p10` / `p25` / `p75` / `p90`(仍保留 `value` / `percentileRank` / `average` / `median` / `upper1Std` / `lower1Std`)
76
-
77
- ### v0.13.0 — 2026-05-15
78
-
79
- **新增接口**
80
- - `fundamental income-statement-hk / balance-sheet-hk / cash-flow-hk` — 港股三大报表(中国会计准则)
81
- - `alternative edb-search` — 行业指标列表搜索(按关键词匹配指标名称,返回 indicatorId 等元信息)
82
- - `alternative edb-data` — 行业指标时序数据(批量按 indicatorId 拉取时间序列,最多 10 个指标)
83
- - `vault stock-pool-list` — 查询用户自选股股票池列表(poolId / poolName)
84
- - `vault stock-pool-stocks` — 查询股票池证券明细(支持 `--pool-id all` 全量查询)
85
-
86
- **接口变更**
87
- - `fundamental income-statement / balance-sheet / cash-flow / income-statement-quarterly / cash-flow-quarterly` 名称调整为 A股报表(路径不变)
88
- - `ai management-discuss-announcement` `--dimension` 新增 `all` 选项,返回报告中完整的管理层讨论内容(内容可能较长)
89
- - `vault wechat-message-list` 新增 `--security <code>` 参数(按证券代码过滤),返回结果增加 `securityList` 字段
90
-
91
- ### v0.12.0 — 2026-05-10
92
-
93
- **性能 / 架构**
94
- - 翻页并行化:自动翻页接口拉到首页 `total` 后,剩余页通过 `Promise.all` 并发请求(默认并发 5,`GANGTISE_PAGE_CONCURRENCY` 可调)
95
- - 共享 `undici.Agent`:所有请求复用连接池(keep-alive 60s,max 16 连接),避免重复 TLS 握手
96
- - 流式下载:`--output` 指定时二进制响应直接 `pipeline` 到磁盘,不再走内存 `Uint8Array`
97
- - 流式输出:`--format jsonl/csv --output xxx` 且 ≥1000 行时逐行写盘
98
- - Token 内存缓存:Token 在进程内不再每次读盘
99
- - 自动重试:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 自动指数退避重试 2 次
100
- - Token 自愈:8000014/8000015 自动重新登录并重试一次
101
- - 异步轮询退避:`earnings-review` / `viewpoint-debate` 轮询从固定 15s 改为 5→8→13→20→30s 指数退避
102
- - K线自动分片:`quote day-kline --security all` 等全市场查询自动按日期切分并发执行
103
- - 标题缓存:原"读全文→改→写全文"改为内存快照 + 原子写入(temp+rename)
104
-
105
- **调试 / 可观测性**
106
- - 新增 `--verbose` / `GANGTISE_VERBOSE=1`:打印每个请求的耗时、状态码、响应字节数到 stderr
107
-
108
- ### v0.11.1 — 2026-05-10
109
-
110
- **新增接口**
111
- - `insight announcement-hk list/download` — 查询/下载港股公告
112
- - `insight foreign-opinion list` — 查询外资机构观点(外资券商)
113
- - `insight independent-opinion list/download` — 查询/下载外资独立分析师观点
114
- - `reference securities-search` — GTS Code 搜索(按名称/代码/拼音多维度匹配证券)
115
-
116
- **接口变更**
117
- - `insight summary download` 新增可选 `--file-type`(`1`=原始内容 / `2`=HTML),仅影响来源为会议平台的纪要
118
- - `insight announcement list/download` 名称调整为"查询A股公告列表/下载A股公告文件"(路径不变)
119
- - `insight opinion list` 名称调整为"查询内资机构观点列表"(路径不变)
120
-
121
- ### v0.11.0 — 2026-04-17
122
-
123
- - 新增 `ai viewpoint-debate` / `viewpoint-debate-check` — 观点PK(异步)
124
- - 新增 `ai management-discuss-announcement` / `management-discuss-earnings-call` — 管理层讨论
125
-
126
- ### v0.10.9 — 2026-04-10
127
-
128
- - 修复信封检测、版本更新检查、端点去重
129
- - 新增 `quote index-day-kline` 指数日K线
130
- - 新增 `vault wechat-message-list` / `wechat-chatroom-list` 群消息
81
+ > 更早版本(v0.15.0 及之前)的完整更新历史见 [GitHub Releases](https://github.com/gangtiser/gangtise-openapi-cli/releases)。
131
82
 
132
83
  ## 首次安装
133
84
 
@@ -141,6 +92,12 @@ npm install -g gangtise-openapi-cli
141
92
  gangtise --help
142
93
  ```
143
94
 
95
+ 更新到最新版(`gangtise --version` 会自动与线上版本比对):
96
+
97
+ ```bash
98
+ npm update -g gangtise-openapi-cli
99
+ ```
100
+
144
101
  本地开发:
145
102
 
146
103
  ```bash
@@ -150,36 +107,6 @@ npm install
150
107
  npm run dev -- --help
151
108
  ```
152
109
 
153
- ## 发布
154
-
155
- npm 发版通过 GitHub Actions Trusted Publishing 完成,不需要 `NPM_TOKEN`。npm 包设置里的 Trusted Publisher 需要匹配本仓库和 workflow 文件名 `publish.yml`。
156
-
157
- ```bash
158
- npm version patch --no-git-tag-version
159
- npm run prepare
160
- VERSION=$(node -p "require('./package.json').version")
161
- git commit -am "chore: release v$VERSION"
162
- git tag -a "v$VERSION" -m "v$VERSION" # 必须 annotated:--follow-tags 不推 lightweight tag
163
- git push --follow-tags
164
- ```
165
-
166
- 推送 `v*` tag 后,`.github/workflows/publish.yml` 会在 GitHub-hosted runner 上使用 OIDC 发布到 `https://registry.npmjs.org/`。也可以从 GitHub Actions 页面手动运行该 workflow。
167
-
168
- ## 版本更新
169
-
170
- 查看当前版本(自动与线上版本比对):
171
-
172
- ```bash
173
- gangtise --version
174
- ```
175
-
176
- 手动更新到最新版:
177
-
178
- ```bash
179
- npm update -g gangtise-openapi-cli
180
- ```
181
-
182
-
183
110
  ## 环境配置
184
111
 
185
112
  优先读取以下环境变量:
@@ -217,6 +144,7 @@ gangtise-openapi/
217
144
  │ ├── ai.md # AI 能力命令(one-pager / earnings-review / viewpoint-debate 等)
218
145
  │ ├── alternative.md # 行业指标数据库(EDB search / EDB data)
219
146
  │ ├── fundamental.md # 财务数据命令(A股/港股三大报表 / 估值 / 盈利预测 / 股东)
147
+ │ ├── indicator.md # 证券级数据指标 EDE(search / 截面 / 时序)
220
148
  │ ├── insight.md # 投研内容命令(研报 / 观点 / 纪要 / 公告 / 外资)
221
149
  │ ├── quote.md # 行情命令(A股/港股/指数 K 线)
222
150
  │ ├── reference-and-lookup.md # GTS Code 搜索与枚举速查
@@ -269,10 +197,13 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
269
197
  | | `research list` / `download` | 研报(含 Markdown 下载) |
270
198
  | | `foreign-report list` / `download` | 外资研报(含中文翻译下载) |
271
199
  | | `announcement list` / `download` | A股公告(含 Markdown 下载) |
272
- | | `announcement-hk list` / `download` | 港股公告(含下载) |
200
+ | | `announcement-hk list` / `download` | 港股公告(含 PDF/Markdown 下载) |
201
+ | | `announcement-us list` / `download` | 美股公告(含 PDF/Markdown 下载) |
273
202
  | | `foreign-opinion list` | 外资机构观点 |
274
203
  | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
204
+ | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
275
205
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
206
+ | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
276
207
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
277
208
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
278
209
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -285,6 +216,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
285
216
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
286
217
  | | `income-statement-quarterly` / `cash-flow-quarterly` | A股利润表/现金流量表(单季度) |
287
218
  | | `income-statement-hk` / `balance-sheet-hk` / `cash-flow-hk` | 港股三大财务报表(中国会计准则) |
219
+ | | `income-statement-us` / `balance-sheet-us` / `cash-flow-us` | 美股三大财务报表 |
288
220
  | | `main-business` | 主营构成(按地区/产品拆分) |
289
221
  | | `valuation-analysis` | 估值分析 |
290
222
  | | `earning-forecast` | 盈利预测(一致预期) |
@@ -292,6 +224,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
292
224
  | **AI** | `knowledge-batch` | 知识库批量检索 |
293
225
  | | `knowledge-resource-download` | 知识资源下载 |
294
226
  | | `security-clue` | 个股线索 |
227
+ | | `stock-summary` | 个股看点(精炼投研总结,按代码或全市场;仅 A 股/港股) |
295
228
  | | `one-pager` | 一页通 |
296
229
  | | `investment-logic` | 投资逻辑 |
297
230
  | | `peer-comparison` | 同业对比 |
@@ -307,6 +240,9 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
307
240
  | | `my-conference-list` / `my-conference-download` | 我的会议列表与下载 |
308
241
  | | `wechat-message-list` / `wechat-chatroom-list` | 群消息列表与群ID查询 |
309
242
  | | `stock-pool-list` / `stock-pool-stocks` | 自选股股票池列表与证券明细 |
243
+ | **Indicator** | `search` | 证券级数据指标搜索(按名称匹配,返回 indicatorCode 及可传参数 parameterList) |
244
+ | | `cross-section` | 指标截面数据(多指标 × 多证券,单日快照;前置 `search` 拿 code) |
245
+ | | `time-series` | 指标时间序列(多指标 × 单证券 或 单指标 × 多证券,按区间) |
310
246
  | **Alternative** | `edb-search` | 行业指标搜索(按关键词匹配,返回 indicatorId 等元信息) |
311
247
  | | `edb-data` | 行业指标时序数据(批量拉取,最多10个指标) |
312
248
  | | `concept-info` | 题材指数基本信息(投资逻辑/行业空间/竞争格局/催化事件) |
@@ -376,8 +312,10 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
376
312
  - `insight foreign-report list`
377
313
  - `insight announcement list`
378
314
  - `insight announcement-hk list`
315
+ - `insight announcement-us list`
379
316
  - `insight foreign-opinion list`
380
317
  - `insight independent-opinion list`
318
+ - `insight official-account list`
381
319
  - `ai security-clue`
382
320
  - `vault drive-list`
383
321
  - `vault record-list`
@@ -395,7 +333,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
395
333
 
396
334
  ## 智能文件命名
397
335
 
398
- 下载命令(`summary download`、`research download`、`foreign-report download`、`announcement download`、`vault drive-download`、`vault record-download`、`vault my-conference-download`)省略 `--output` 时,自动使用真实标题作为文件名:
336
+ 下载命令(`summary download`、`research download`、`foreign-report download`、`announcement download`、`announcement-hk download`、`announcement-us download`、`official-account download`、`vault drive-download`、`vault record-download`、`vault my-conference-download`)省略 `--output` 时,自动使用真实标题作为文件名:
399
337
 
400
338
  1. **缓存优先** — 如果之前执行过对应的 `list` 命令,标题已缓存在 `~/.config/gangtise/title-cache.json`,直接使用,无额外 API 调用
401
339
  2. **API 回查** — 缓存未命中时,自动查询最近 200 条记录匹配标题
@@ -446,6 +384,11 @@ gangtise insight roadshow list --institution C100000017
446
384
  # 港股公告
447
385
  gangtise insight announcement-hk list --security 01913.HK --rank-type 2 --size 20 --format json
448
386
  gangtise insight announcement-hk download --announcement-id ANN2026040200012345
387
+ gangtise insight announcement-hk download --announcement-id ANN2026040200012345 --file-type 2 # Markdown
388
+
389
+ # 美股公告(--security 用美股代码;分类用 reference constant-list --category usShareAnnouncementCategory)
390
+ gangtise insight announcement-us list --security TSLA.O --rank-type 2 --size 20 --format json
391
+ gangtise insight announcement-us download --announcement-id 49629029 --file-type 2 # Markdown
449
392
 
450
393
  # 外资机构观点
451
394
  gangtise insight foreign-opinion list --keyword "自动驾驶" --region us --rank-type 2 --format json
@@ -455,6 +398,10 @@ gangtise insight foreign-opinion list --security APP.O --rating buy --format jso
455
398
  gangtise insight independent-opinion list --keyword "肿瘤" --industry 100800118 --format json
456
399
  gangtise insight independent-opinion download --independent-opinion-id 207051900018372 --file-type 2
457
400
 
401
+ # 产业公众号资讯
402
+ gangtise insight official-account list --keyword 泡泡玛特 --rank-type 2 --size 20 --format json
403
+ gangtise insight official-account download --article-id 7286248 --file-type 2
404
+
458
405
  # 纪要下载(会议平台来源可选 HTML 格式)
459
406
  gangtise insight summary download --summary-id 4906813 --file-type 2
460
407
  ```
@@ -468,10 +415,15 @@ gangtise reference securities-search --keyword "600519" --category stock
468
415
  gangtise reference securities-search --keyword gzmt --top 5
469
416
  gangtise reference securities-search --keyword "银行" --category stock --category index
470
417
 
418
+ # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
419
+ gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
420
+ gangtise reference chiefs-search --keyword 芦哲 --format json
421
+
471
422
  # 常量查询:先看分类,再按分类导出全量常量值
472
423
  gangtise reference constant-category --format json
473
424
  gangtise reference constant-list --category citicIndustry --format json
474
425
  gangtise reference constant-list --category aShareAnnouncementCategory --format json # 树形,含 children
426
+ gangtise reference constant-list --category usShareAnnouncementCategory --format json # 美股公告分类(103980xxx 段)
475
427
 
476
428
  # 题材 ID 搜索(供 concept-info / concept-securities / theme-tracking 使用)
477
429
  gangtise reference concept-search --keyword 机器人 --top 3 --format json
@@ -546,6 +498,11 @@ gangtise fundamental balance-sheet-hk --security-code 09992.HK --fiscal-year 202
546
498
  gangtise fundamental cash-flow-hk --security-code 09992.HK --fiscal-year 2025 --period annual --field netOpCashFlows --field netInvCashFlows --field netFinCashFlows
547
499
  # 最新一期完整港股利润表
548
500
  gangtise fundamental income-statement-hk --security-code 09992.HK --format json
501
+
502
+ # 美股三大报表(--security-code 用美股代码;period 同港股但无 h2)
503
+ gangtise fundamental income-statement-us --security-code TSLA.O --period latest --format json
504
+ gangtise fundamental balance-sheet-us --security-code TSLA.O --fiscal-year 2025 --period annual --field totalAssets --field totalLiab --field totalEquity
505
+ gangtise fundamental cash-flow-us --security-code TSLA.O --fiscal-year 2024 --fiscal-year 2025 --period annual --field netOpCashFlows
549
506
  ```
550
507
 
551
508
  ### AI
@@ -556,6 +513,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
556
513
  gangtise ai knowledge-batch --query 新能源汽车 --resource-type 10 --resource-type 11 --top 10
557
514
  gangtise ai security-clue --start-time "2026-04-01 00:00:00" --end-time "2026-04-09 23:59:59" --query-mode byIndustry --gts-code 821035.SWI --source researchReport --source announcement
558
515
  gangtise ai one-pager --security-code 600519.SH
516
+ # 个股看点(精炼投研总结,仅 A 股/港股):传具体代码,或 aShares/hkStocks 拉全市场
517
+ gangtise ai stock-summary --security 600519.SH --security 00700.HK --format json
518
+ gangtise ai stock-summary --security hkStocks --format json
559
519
  gangtise ai investment-logic --security-code 600519.SH
560
520
  gangtise ai peer-comparison --security-code 600519.SH
561
521
  gangtise ai earnings-review --security-code 600519.SH --period 2025q3
@@ -613,6 +573,34 @@ gangtise vault stock-pool-stocks --pool-id 808477293
613
573
  gangtise vault stock-pool-stocks
614
574
  ```
615
575
 
576
+ ### Indicator(证券级数据指标 EDE)
577
+
578
+ ```bash
579
+ # Step 1:按名称搜索,拿 indicatorCode(绝不猜编码);--format json 看可传参数 parameterList 及 required
580
+ gangtise indicator search --keyword 收盘价 --format table # → qte_close
581
+ gangtise indicator search --keyword 平均ROE --limit 5 --format json # 看 parameterList
582
+
583
+ # 截面:多指标 × 多证券,单日快照(行情类用交易日;财务类用报告期末,如 2026-03-31)
584
+ gangtise indicator cross-section \
585
+ --indicator qte_close --indicator qte_vol --indicator qte_mkt_cptl \
586
+ --security 600519.SH --security 09992.HK \
587
+ --date 2026-05-18 --format table
588
+
589
+ # 时间序列:多指标 × 单证券 或 单指标 × 多证券(不能多 × 多,否则报 410001)
590
+ gangtise indicator time-series --indicator qte_close \
591
+ --security 600519.SH --security 09992.HK \
592
+ --start-date 2026-05-12 --end-date 2026-05-18 --format table
593
+
594
+ # 复权 / 指标专属参数用 --indicator-param "code:key=value",参数 key 以 search 的 parameterList 为准
595
+ gangtise indicator cross-section --indicator qte_close --security 600519.SH \
596
+ --date 2026-05-18 --indicator-param "qte_close:adjustmentType=3" # 1不复权/2前复权/3后复权
597
+
598
+ # 必填参数:很多指标默认调用报 410106(缺必填参数),按 parameterList 的 required 补齐再取:
599
+ # N 期统计补 periodNum、区间/周期类(如 qte_amp_mo 月振幅)补 startDate、年度/分红类补 fiscalYear
600
+ gangtise indicator cross-section --indicator finc_roe_avg_avg --security 600519.SH \
601
+ --date 2026-03-31 --indicator-param "finc_roe_avg_avg:periodNum=4"
602
+ ```
603
+
616
604
  ### Alternative(行业指标数据库 EDB)
617
605
 
618
606
  ```bash
@@ -694,3 +682,22 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
694
682
  | `430007` | 行情查询超出限制(数据量过大,请缩短日期范围或减少 `--limit`) |
695
683
  | `410110` | 异步任务生成中(非终态,需继续轮询) |
696
684
  | `410111` | 异步任务生成失败(终态,不可重试) |
685
+
686
+ ---
687
+
688
+ ## 发布(维护者)
689
+
690
+ > 面向仓库维护者的发版流程,普通用户可跳过。
691
+
692
+ npm 发版通过 GitHub Actions Trusted Publishing 完成,不需要 `NPM_TOKEN`。npm 包设置里的 Trusted Publisher 需要匹配本仓库和 workflow 文件名 `publish.yml`。
693
+
694
+ ```bash
695
+ npm version patch --no-git-tag-version
696
+ npm run prepare
697
+ VERSION=$(node -p "require('./package.json').version")
698
+ git commit -am "chore: release v$VERSION"
699
+ git tag -a "v$VERSION" -m "v$VERSION" # 必须 annotated:--follow-tags 不推 lightweight tag
700
+ git push --follow-tags
701
+ ```
702
+
703
+ 推送 `v*` tag 后,`.github/workflows/publish.yml` 会在 GitHub-hosted runner 上使用 OIDC 发布到 `https://registry.npmjs.org/`。也可以从 GitHub Actions 页面手动运行该 workflow。
package/dist/src/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from "commander";
3
3
  import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
- import { readTokenCache } from "./core/auth.js";
4
+ import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
5
  import { collectKeyValue, collectList, collectNumberList, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
6
- import { buildQuoteKlineBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
6
+ import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
+ import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
7
8
  import { callKlineWithSharding } from "./core/quoteSharding.js";
8
9
  import { loadConfig } from "./core/config.js";
9
10
  import { resolveTitle, saveDownloadResult } from "./core/download.js";
10
11
  import { ENDPOINTS } from "./core/endpoints.js";
11
- import { ApiError, ConfigError } from "./core/errors.js";
12
+ import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
12
13
  import { normalizeRows } from "./core/normalize.js";
13
14
  import { parseOutputFormat } from "./core/output.js";
14
15
  import { printData } from "./core/printer.js";
@@ -99,14 +100,18 @@ program
99
100
  .command("auth")
100
101
  .description("Authentication commands")
101
102
  .addCommand(new Command("login")
103
+ .option("--show-token", "Show the raw access token (default: redacted)")
102
104
  .option("--format <format>", "Output format", "json")
103
- .action((options) => emit(options, (client) => client.login())))
105
+ .action((options) => emit(options, async (client) => {
106
+ const result = await client.login();
107
+ return options.showToken ? result : { authorization: "<redacted>", cache: redactTokenCache(result.cache) };
108
+ })))
104
109
  .addCommand(new Command("status")
105
110
  .option("--format <format>", "Output format", "json")
106
111
  .action(async (options) => {
107
112
  const config = loadConfig();
108
113
  const cache = await readTokenCache(config.tokenCachePath);
109
- await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache }, parseOutputFormat(options.format));
114
+ await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache: redactTokenCache(cache) }, parseOutputFormat(options.format));
110
115
  }));
111
116
  const lookup = new Command("lookup").description("Local lookup tables (IDs not covered by 'reference constant-list')");
112
117
  const addLookupList = (name, endpointKey, description) => {
@@ -129,6 +134,7 @@ const research = new Command("research");
129
134
  const foreignReport = new Command("foreign-report");
130
135
  const announcement = new Command("announcement");
131
136
  const announcementHk = new Command("announcement-hk");
137
+ const announcementUs = new Command("announcement-us");
132
138
  const foreignOpinion = new Command("foreign-opinion");
133
139
  const independentOpinion = new Command("independent-opinion");
134
140
  const officialAccount = new Command("official-account");
@@ -214,6 +220,12 @@ addTimeFilters(foreignReport.command("list").option("--search-type <number>", "S
214
220
  minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
215
221
  }), { endpointKey: "insight.foreign-report.list", idField: "reportId" }));
216
222
  addDownloadCommand(foreignReport, { endpointKey: "insight.foreign-report.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "foreign-report", fileType: { description: "File type: 1=PDF 2=Markdown 3=CN-PDF 4=CN-Markdown", default: "1" }, titleListEndpoint: "insight.foreign-report.list" });
223
+ // Contract: A-share announcement startTime/endTime go out as 13-digit epoch millis
224
+ // (parseTimestamp13), while HK/US announcement and every other insight list send the
225
+ // datetime string straight through. All three filter correctly — verified live against
226
+ // a narrow past window (each returns in-window rows). A-share's API also accepts the
227
+ // string form, but the 13-digit conversion is kept as the historical spec contract;
228
+ // don't "unify" it away without re-confirming the A-share announcement spec.
217
229
  addTimeFilters(announcement.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement.list", {
218
230
  from: parseFrom(options.from), size: parseSize(options.size),
219
231
  startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
@@ -229,7 +241,16 @@ addTimeFilters(announcementHk.command("list").option("--search-type <number>", "
229
241
  keyword: options.keyword,
230
242
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
231
243
  }), { endpointKey: "insight.announcement-hk.list", idField: "announcementId" }));
232
- addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", titleListEndpoint: "insight.announcement-hk.list" });
244
+ addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", fileType: { description: "File type: 1=original 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement-hk.list" });
245
+ addTimeFilters(announcementUs.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. TSLA.O)", collectList, []).option("--category <id>", "Category ID (constant-list usShareAnnouncementCategory)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement-us.list", {
246
+ from: parseFrom(options.from), size: parseSize(options.size),
247
+ startTime: options.startTime, endTime: options.endTime,
248
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
249
+ rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
250
+ keyword: options.keyword,
251
+ securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
252
+ }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
253
+ addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
233
254
  addTimeFilters(foreignOpinion.command("list").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
234
255
  from: parseFrom(options.from), size: parseSize(options.size),
235
256
  startTime: options.startTime, endTime: options.endTime,
@@ -268,6 +289,7 @@ insight.addCommand(research);
268
289
  insight.addCommand(foreignReport);
269
290
  insight.addCommand(announcement);
270
291
  insight.addCommand(announcementHk);
292
+ insight.addCommand(announcementUs);
271
293
  insight.addCommand(foreignOpinion);
272
294
  insight.addCommand(independentOpinion);
273
295
  insight.addCommand(officialAccount);
@@ -299,6 +321,9 @@ addFinancialReport("cash-flow-quarterly", "fundamental.cash-flow-quarterly", "Pe
299
321
  addFinancialReport("income-statement-hk", "fundamental.income-statement-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
300
322
  addFinancialReport("balance-sheet-hk", "fundamental.balance-sheet-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
301
323
  addFinancialReport("cash-flow-hk", "fundamental.cash-flow-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
324
+ addFinancialReport("income-statement-us", "fundamental.income-statement-us", "Period: q1/h1/q3/nsd/annual/latest");
325
+ addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period: q1/h1/q3/nsd/annual/latest");
326
+ addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
302
327
  fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").addOption(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product")).option("--period <type>", "Period: interim/annual", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) })));
303
328
  fundamental.command("valuation-analysis").requiredOption("--security-code <code>").addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--limit <number>").option("--field <field>", "Field", collectList, []).option("--skip-null", "Drop rows where value or percentileRank is null").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
304
329
  let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) });
@@ -327,7 +352,11 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
327
352
  }));
328
353
  program.addCommand(fundamental);
329
354
  const ai = new Command("ai").description("AI APIs");
330
- ai.command("knowledge-batch").requiredOption("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) })));
355
+ ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
356
+ if (!options.query.length)
357
+ throw new ValidationError("--query is required: pass at least one --query");
358
+ return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
359
+ });
331
360
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
332
361
  await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
333
362
  output: options.output,
@@ -371,8 +400,8 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
371
400
  startDate: options.startDate,
372
401
  endDate: options.endDate,
373
402
  categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
374
- withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
375
- withCloseReading: options.withCloseReading === false ? undefined : true,
403
+ withRelatedSecurities: options.withRelatedSecurities !== false,
404
+ withCloseReading: options.withCloseReading !== false,
376
405
  });
377
406
  }));
378
407
  ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension: businessOperation/financialPerformance/developmentAndRisk/all").choices(["businessOperation", "financialPerformance", "developmentAndRisk", "all"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.management-discuss-announcement", {
@@ -405,6 +434,13 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
405
434
  }
406
435
  }));
407
436
  ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient((client) => checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output)));
437
+ ai.command("stock-summary").description("Stock highlights: refined research summary per security (A-share / HK)").option("--security <code>", "Security code (e.g. 600519.SH / 00700.HK), or market keyword: aShares / hkStocks; max 6000", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
438
+ // Guard against an empty --security: omitting it would send securityList:undefined,
439
+ // which the backend may treat as all-market (3 credits/row × thousands of rows).
440
+ if (!options.security.length)
441
+ throw new ValidationError("--security is required: pass security code(s) or a market keyword (aShares / hkStocks)");
442
+ return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
443
+ });
408
444
  const reference = new Command("reference").description("Reference data APIs");
409
445
  reference.command("securities-search").requiredOption("--keyword <text>", "Search keyword (name/code/pinyin/English)").option("--category <type>", "Category: stock/dr/index/fund", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.securities-search", {
410
446
  keyword: options.keyword,
@@ -422,6 +458,10 @@ reference.command("sector-search").option("--keyword <text>", "Search keyword (n
422
458
  top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
423
459
  })));
424
460
  reference.command("sector-constituents").requiredOption("--sector-id <id>", "Sector ID from 'reference sector-search'").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-constituents", { sectorId: options.sectorId })));
461
+ reference.command("chiefs-search").requiredOption("--keyword <text>", "Search keyword (chief name / institution / team)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.chiefs-search", {
462
+ keyword: options.keyword,
463
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
464
+ })));
425
465
  program.addCommand(reference);
426
466
  const vault = new Command("vault").description("Vault APIs");
427
467
  vault.command("drive-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--file-type <number>", "File type", collectNumberList, []).option("--space-type <number>", "Space type", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.drive.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, fileTypeList: options.fileType.length ? options.fileType : undefined, spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), { endpointKey: "vault.drive.list", idField: "fileId" }));
@@ -433,7 +473,7 @@ addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "
433
473
  vault.command("wechat-message-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
434
474
  vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Rows to return", "20").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
435
475
  vault.command("stock-pool-list").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.list", {})));
436
- vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; use 'all' for all pools", collectList, ["all"]).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", { poolIdList: options.poolId })));
476
+ vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", buildStockPoolStocksBody(options))));
437
477
  program.addCommand(vault);
438
478
  program.addCommand(ai);
439
479
  const alternative = new Command("alternative").description("Alternative data APIs");
@@ -460,6 +500,23 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
460
500
  alternative.command("concept-info").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-info", { conceptId: options.conceptId })));
461
501
  alternative.command("concept-securities").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-securities", { conceptId: options.conceptId })));
462
502
  program.addCommand(alternative);
503
+ const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series");
504
+ indicator.command("search").requiredOption("--keyword <text>", "Search keyword, e.g. '收盘价' '成交量' '营业收入' (not free-form questions)").option("--limit <number>", "Max results (default: 50, max: 100)", "50").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
505
+ const raw = await client.call("indicator.search", {
506
+ keyword: options.keyword,
507
+ limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
508
+ });
509
+ await printData(unwrapIndicatorData(raw), parseOutputFormat(options.format), options.output);
510
+ }));
511
+ indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH (repeat for multiple)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustmentType=2 for 前复权 (repeat)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
512
+ const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
513
+ await printData(flattenCrossSection(unwrapIndicatorData(raw)), parseOutputFormat(options.format), options.output);
514
+ }));
515
+ indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH (repeat for multiple)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday (default TD)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustmentType=2 for 前复权 (repeat)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
516
+ const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
517
+ await printData(flattenTimeSeries(unwrapIndicatorData(raw)), parseOutputFormat(options.format), options.output);
518
+ }));
519
+ program.addCommand(indicator);
463
520
  program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
464
521
  const endpoint = ENDPOINTS[endpointKey];
465
522
  if (!endpoint) {
@@ -79,3 +79,29 @@ export function parseTimestamp13(value, optionName) {
79
79
  }
80
80
  return parsed;
81
81
  }
82
+ // Parse repeatable `--indicator-param "code:key=value"` specs into the nested
83
+ // indicatorParamList the EDE cross-section / time-series endpoints expect.
84
+ // Multiple specs for the same code accumulate into one group, first-seen order.
85
+ export function parseIndicatorParams(specs) {
86
+ if (specs.length === 0)
87
+ return undefined;
88
+ const groups = new Map();
89
+ for (const spec of specs) {
90
+ const colon = spec.indexOf(":");
91
+ const rest = colon === -1 ? "" : spec.slice(colon + 1);
92
+ const eq = rest.indexOf("=");
93
+ const code = colon === -1 ? "" : spec.slice(0, colon).trim();
94
+ const paramKey = eq === -1 ? "" : rest.slice(0, eq).trim();
95
+ const paramValue = eq === -1 ? "" : rest.slice(eq + 1).trim();
96
+ if (!code || !paramKey) {
97
+ throw new ValidationError(`Invalid --indicator-param: expected "code:key=value", got "${spec}"`);
98
+ }
99
+ let group = groups.get(code);
100
+ if (!group) {
101
+ group = { indicatorCode: code, parameters: [] };
102
+ groups.set(code, group);
103
+ }
104
+ group.parameters.push({ paramKey, paramValue });
105
+ }
106
+ return [...groups.values()];
107
+ }
@@ -14,6 +14,23 @@ export async function readTokenCache(filePath) {
14
14
  return null;
15
15
  }
16
16
  }
17
+ /**
18
+ * Return a display-safe copy of a token cache for `auth status`: any field whose
19
+ * name matches a credential pattern (token / key / secret / password / credential)
20
+ * is replaced with "<redacted>" so the raw bearer token — or any unknown credential
21
+ * field the cache file might carry (apiKey, privateKey, …) — is never printed; all
22
+ * other metadata (expiresAt, userName, productCode, …) is preserved.
23
+ */
24
+ export function redactTokenCache(cache) {
25
+ if (!cache)
26
+ return null;
27
+ const SENSITIVE = /token|secret|password|credential|key/i;
28
+ const out = {};
29
+ for (const [key, value] of Object.entries(cache)) {
30
+ out[key] = SENSITIVE.test(key) ? "<redacted>" : value;
31
+ }
32
+ return out;
33
+ }
17
34
  export async function writeTokenCache(filePath, cache) {
18
35
  await fs.mkdir(path.dirname(filePath), { recursive: true });
19
36
  await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
@@ -30,7 +47,12 @@ export function normalizeToken(token) {
30
47
  }
31
48
  export function requireAccessCredentials(accessKey, secretKey) {
32
49
  if (!accessKey || !secretKey) {
33
- throw new ConfigError("Missing GANGTISE_ACCESS_KEY or GANGTISE_SECRET_KEY");
50
+ const missing = [!accessKey && "GANGTISE_ACCESS_KEY", !secretKey && "GANGTISE_SECRET_KEY"].filter(Boolean).join(", ");
51
+ throw new ConfigError(`缺少环境变量: ${missing}(未导出到当前进程环境)\n`
52
+ + `注意:在 shell 里赋值还不够,必须"导出",子进程才读得到:\n`
53
+ + ` bash/zsh: export GANGTISE_ACCESS_KEY=... GANGTISE_SECRET_KEY=...\n`
54
+ + ` fish: set -gx GANGTISE_ACCESS_KEY ...; set -gx GANGTISE_SECRET_KEY ...\n`
55
+ + `验证:env | grep GANGTISE(能列出对应行才算导出成功)`);
34
56
  }
35
57
  return { accessKey, secretKey };
36
58
  }
@@ -17,11 +17,14 @@ export class GangtiseClient {
17
17
  config;
18
18
  refreshPromise = null;
19
19
  memoCache = null;
20
+ // After an injected env token (GANGTISE_TOKEN) is rejected and we self-heal via
21
+ // login, stop preferring that now-stale token so the retry uses the fresh one.
22
+ envTokenInvalidated = false;
20
23
  constructor(config) {
21
24
  this.config = config;
22
25
  }
23
26
  async getAuthorizationHeader(forceRefresh = false) {
24
- if (this.config.token && !forceRefresh) {
27
+ if (this.config.token && !this.envTokenInvalidated && !forceRefresh) {
25
28
  return normalizeToken(this.config.token);
26
29
  }
27
30
  if (!forceRefresh) {
@@ -69,6 +72,7 @@ export class GangtiseClient {
69
72
  && this.config.secretKey) {
70
73
  authState.retried = true;
71
74
  this.memoCache = null;
75
+ this.envTokenInvalidated = true;
72
76
  await this.getAuthorizationHeader(true);
73
77
  throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
74
78
  }
@@ -175,19 +179,40 @@ export class GangtiseClient {
175
179
  }
176
180
  let unexpectedShape = false;
177
181
  let totalDrift = false;
182
+ // Fail-soft fan-out: a hard page failure (rate-limit 903301, no-perm, retries
183
+ // exhausted) must NOT discard the pages already fetched. Catch per page, record
184
+ // it, and stop starting new requests so we don't keep burning quota into a rate
185
+ // limit. Mirrors quoteSharding's partial-result tolerance — but firstPage already
186
+ // succeeded to get here, so unlike sharding there's no total-failure case.
187
+ const failedPages = [];
188
+ let firstError = null;
189
+ let aborted = false;
178
190
  const pages = await runWithConcurrency(pageRequests, PAGINATION_CONCURRENCY, async (req) => {
179
- const page = await this.requestJson(endpoint, {
180
- ...initialBody,
181
- from: req.from,
182
- size: req.size,
183
- });
184
- if (!this.isPaginatedListResponse(page)) {
185
- unexpectedShape = true;
191
+ if (aborted) {
192
+ failedPages.push(req);
193
+ return [];
194
+ }
195
+ try {
196
+ const page = await this.requestJson(endpoint, {
197
+ ...initialBody,
198
+ from: req.from,
199
+ size: req.size,
200
+ });
201
+ if (!this.isPaginatedListResponse(page)) {
202
+ unexpectedShape = true;
203
+ return [];
204
+ }
205
+ if (page.total !== total)
206
+ totalDrift = true;
207
+ return page.list;
208
+ }
209
+ catch (error) {
210
+ if (!firstError)
211
+ firstError = error;
212
+ aborted = true;
213
+ failedPages.push(req);
186
214
  return [];
187
215
  }
188
- if (page.total !== total)
189
- totalDrift = true;
190
- return page.list;
191
216
  });
192
217
  for (const list of pages) {
193
218
  if (list.length === 0)
@@ -206,11 +231,18 @@ export class GangtiseClient {
206
231
  if (truncatedByPageCap) {
207
232
  process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} of ${total} rows. Narrow the query (e.g. a shorter date range) or pass --size to fetch a bounded subset.\n`);
208
233
  }
209
- return {
234
+ const out = {
210
235
  ...firstPage,
211
236
  total,
212
237
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
213
238
  };
239
+ if (failedPages.length > 0) {
240
+ out.partial = true;
241
+ out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
242
+ const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
243
+ process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages). A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped.\n`);
244
+ }
245
+ return out;
214
246
  }
215
247
  async login() {
216
248
  const authorization = await this.getAuthorizationHeader();
@@ -1,4 +1,4 @@
1
- import { maybeArray, parseFrom, parseOptionalNumberOption, parseSize } from "./args.js";
1
+ import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseSize } from "./args.js";
2
2
  export function buildQuoteKlineBody(options) {
3
3
  return {
4
4
  securityList: maybeArray(options.security),
@@ -29,3 +29,30 @@ export function buildWechatChatroomListBody(options) {
29
29
  roomName: options.roomName.length > 0 ? options.roomName.join(",") : undefined,
30
30
  };
31
31
  }
32
+ export function buildStockPoolStocksBody(options) {
33
+ return {
34
+ poolIdList: options.poolId?.length ? options.poolId : ["all"],
35
+ };
36
+ }
37
+ export function buildIndicatorCrossSectionBody(options) {
38
+ return {
39
+ indicatorCodeList: maybeArray(options.indicator),
40
+ securityCodeList: maybeArray(options.security),
41
+ date: options.date,
42
+ currency: options.currency,
43
+ scale: options.scale,
44
+ indicatorParamList: parseIndicatorParams(options.indicatorParam),
45
+ };
46
+ }
47
+ export function buildIndicatorTimeSeriesBody(options) {
48
+ return {
49
+ indicatorCodeList: maybeArray(options.indicator),
50
+ securityCodeList: maybeArray(options.security),
51
+ startDate: options.startDate,
52
+ endDate: options.endDate,
53
+ calendarType: options.calendarType,
54
+ currency: options.currency,
55
+ scale: options.scale,
56
+ indicatorParamList: parseIndicatorParams(options.indicatorParam),
57
+ };
58
+ }
@@ -2,6 +2,12 @@ import { extname } from "node:path";
2
2
  import { DownloadError } from "./errors.js";
3
3
  import { saveOutputIfNeeded } from "./output.js";
4
4
  import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCache.js";
5
+ /** Replace filesystem-unsafe characters with `_` so a title or a server-supplied
6
+ * filename can't create stray subdirectories or escape the intended output path.
7
+ * Shared by title-based naming and the download fallback. */
8
+ function sanitizeFilename(name) {
9
+ return name.replace(/[/\\:*?"<>|]/g, "_");
10
+ }
5
11
  const MIME_EXT = {
6
12
  "application/pdf": ".pdf",
7
13
  "application/msword": ".doc",
@@ -38,7 +44,7 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
38
44
  const file = result;
39
45
  const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
40
46
  function buildFilename(rawTitle) {
41
- let title = rawTitle.replace(/[/\\:*?"<>|]/g, "_").trim();
47
+ let title = sanitizeFilename(rawTitle).trim();
42
48
  if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
43
49
  title += serverExt;
44
50
  }
@@ -76,7 +82,9 @@ export async function saveDownloadResult(result, fallbackName, output) {
76
82
  return;
77
83
  }
78
84
  if (file.data instanceof Uint8Array) {
79
- const outputPath = output ?? file.filename ?? (fallbackName + extFromContentType(file.contentType));
85
+ // Sanitize the server-provided filename so a Content-Disposition value with
86
+ // / or : can't write outside the intended path (same rule as buildFilename).
87
+ const outputPath = output ?? (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (fallbackName + extFromContentType(file.contentType));
80
88
  await saveOutputIfNeeded(file.data, outputPath);
81
89
  process.stdout.write(`${outputPath}\n`);
82
90
  return;
@@ -138,6 +138,21 @@ export const ENDPOINTS = {
138
138
  kind: "download",
139
139
  description: "Download HK announcement file",
140
140
  },
141
+ "insight.announcement-us.list": {
142
+ key: "insight.announcement-us.list",
143
+ method: "POST",
144
+ path: "/application/open-insight/announcement-us/getList",
145
+ kind: "json",
146
+ description: "List US announcements",
147
+ pagination: { enabled: true, maxPageSize: 50 },
148
+ },
149
+ "insight.announcement-us.download": {
150
+ key: "insight.announcement-us.download",
151
+ method: "GET",
152
+ path: "/application/open-insight/announcement-us/download/file",
153
+ kind: "download",
154
+ description: "Download US announcement file",
155
+ },
141
156
  "insight.foreign-opinion.list": {
142
157
  key: "insight.foreign-opinion.list",
143
158
  method: "POST",
@@ -184,6 +199,13 @@ export const ENDPOINTS = {
184
199
  kind: "json",
185
200
  description: "Search GTS codes (securities)",
186
201
  },
202
+ "reference.chiefs-search": {
203
+ key: "reference.chiefs-search",
204
+ method: "POST",
205
+ path: "/application/open-reference/chiefs/search",
206
+ kind: "json",
207
+ description: "Search chief analyst IDs by name / institution / team",
208
+ },
187
209
  "reference.constant-category": {
188
210
  key: "reference.constant-category",
189
211
  method: "GET",
@@ -319,6 +341,27 @@ export const ENDPOINTS = {
319
341
  kind: "json",
320
342
  description: "Query HK cash flow statement (China GAAP)",
321
343
  },
344
+ "fundamental.income-statement-us": {
345
+ key: "fundamental.income-statement-us",
346
+ method: "POST",
347
+ path: "/application/open-fundamental/financial-report/income-statement/us",
348
+ kind: "json",
349
+ description: "Query US income statement",
350
+ },
351
+ "fundamental.balance-sheet-us": {
352
+ key: "fundamental.balance-sheet-us",
353
+ method: "POST",
354
+ path: "/application/open-fundamental/financial-report/balance-sheet/us",
355
+ kind: "json",
356
+ description: "Query US balance sheet",
357
+ },
358
+ "fundamental.cash-flow-us": {
359
+ key: "fundamental.cash-flow-us",
360
+ method: "POST",
361
+ path: "/application/open-fundamental/financial-report/cash-flow-statement/us",
362
+ kind: "json",
363
+ description: "Query US cash flow statement",
364
+ },
322
365
  "fundamental.main-business": {
323
366
  key: "fundamental.main-business",
324
367
  method: "POST",
@@ -348,6 +391,13 @@ export const ENDPOINTS = {
348
391
  description: "Query earning forecast (consensus estimates)",
349
392
  },
350
393
  // ─── ai ───
394
+ "ai.stock-summary.list": {
395
+ key: "ai.stock-summary.list",
396
+ method: "POST",
397
+ path: "/application/open-ai/stock-summary/getList",
398
+ kind: "json",
399
+ description: "Stock highlights (refined research summary per security)",
400
+ },
351
401
  "ai.knowledge-batch": {
352
402
  key: "ai.knowledge-batch",
353
403
  method: "POST",
@@ -559,4 +609,26 @@ export const ENDPOINTS = {
559
609
  kind: "json",
560
610
  description: "Query concept (theme index) constituent securities, grouped",
561
611
  },
612
+ // ─── indicator (EDE: security-level data indicators) ───
613
+ "indicator.search": {
614
+ key: "indicator.search",
615
+ method: "POST",
616
+ path: "/application/open-indicator/EDE/search",
617
+ kind: "json",
618
+ description: "Search data indicators by keyword (returns indicatorCode + params)",
619
+ },
620
+ "indicator.cross-section": {
621
+ key: "indicator.cross-section",
622
+ method: "POST",
623
+ path: "/application/open-indicator/EDE/cross-section",
624
+ kind: "json",
625
+ description: "Get cross-section data (multi-indicator x multi-security, single date)",
626
+ },
627
+ "indicator.time-series": {
628
+ key: "indicator.time-series",
629
+ method: "POST",
630
+ path: "/application/open-indicator/EDE/time-series",
631
+ kind: "json",
632
+ description: "Get time-series data (multi-indicator x single-security OR single-indicator x multi-security)",
633
+ },
562
634
  };
@@ -0,0 +1,94 @@
1
+ import { ApiError } from "./errors.js";
2
+ // The EDE endpoints double-wrap on success: the shared client strips the outer
3
+ // envelope but leaves an inner { code, status, data } around the real payload.
4
+ // Peel that inner envelope so the list (search) / matrix (cross-section,
5
+ // time-series) is reachable. Observed errors arrive single-enveloped (the
6
+ // client throws on those), but a failure code carried only by the inner
7
+ // envelope must still surface instead of rendering its null payload as success.
8
+ export function unwrapIndicatorData(raw) {
9
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
10
+ const record = raw;
11
+ if ("data" in record && ("code" in record || "status" in record)) {
12
+ const code = record.code === undefined ? undefined : String(record.code);
13
+ const ok = record.status === true || code === "000000" || code === "0";
14
+ if (!ok) {
15
+ throw new ApiError(typeof record.msg === "string" && record.msg ? record.msg : "Indicator API request failed", code);
16
+ }
17
+ return record.data;
18
+ }
19
+ }
20
+ return raw;
21
+ }
22
+ function asStringArray(value) {
23
+ return Array.isArray(value) ? value.map((item) => String(item)) : undefined;
24
+ }
25
+ function rowOf(values, index) {
26
+ const row = values[index];
27
+ return Array.isArray(row) ? row : undefined;
28
+ }
29
+ // Build one column header per series. Prefer the human-readable name; on a
30
+ // duplicate name append the code so a column is never silently overwritten.
31
+ function buildHeaders(names, codes, count) {
32
+ const used = new Set();
33
+ const headers = [];
34
+ for (let i = 0; i < count; i++) {
35
+ const base = String(names?.[i] ?? codes?.[i] ?? `col${i}`);
36
+ let header = base;
37
+ let attempt = 1;
38
+ while (used.has(header)) {
39
+ const suffix = codes?.[i] ?? i;
40
+ header = attempt === 1 ? `${base} (${suffix})` : `${base} (${suffix})_${attempt}`;
41
+ attempt++;
42
+ }
43
+ used.add(header);
44
+ headers.push(header);
45
+ }
46
+ return headers;
47
+ }
48
+ // Cross-section: one row per security, one column per indicator. The live
49
+ // `values` is a 2D [numIndicators][numSecurities] matrix in indicator-major
50
+ // order, so indicator i on security j is values[i][j].
51
+ export function flattenCrossSection(data) {
52
+ if (!data || typeof data !== "object")
53
+ return data;
54
+ const d = data;
55
+ const securityCode = asStringArray(d.securityCodeList);
56
+ const indicators = asStringArray(d.indicatorCodeList);
57
+ if (!Array.isArray(d.values) || !securityCode || !indicators)
58
+ return data;
59
+ const securityName = asStringArray(d.securityNameList);
60
+ const headers = buildHeaders(asStringArray(d.indicatorNameList), indicators, indicators.length);
61
+ const list = securityCode.map((code, j) => {
62
+ const row = { date: d.date, security: code, name: securityName?.[j] };
63
+ for (let i = 0; i < indicators.length; i++) {
64
+ row[headers[i]] = rowOf(d.values, i)?.[j];
65
+ }
66
+ return row;
67
+ });
68
+ return { list, total: list.length };
69
+ }
70
+ // Time-series: one row per date. Columns are the indicators (single-security
71
+ // case) or the securities (single-indicator case) — exactly one dimension
72
+ // varies, per the API contract. `values` is a 2D [series][date] matrix.
73
+ export function flattenTimeSeries(data) {
74
+ if (!data || typeof data !== "object")
75
+ return data;
76
+ const d = data;
77
+ const dates = asStringArray(d.dates);
78
+ const securityCode = asStringArray(d.securityCodeList);
79
+ const indicators = asStringArray(d.indicatorCodeList);
80
+ if (!Array.isArray(d.values) || !dates || !securityCode || !indicators)
81
+ return data;
82
+ const seriesAreIndicators = securityCode.length <= 1;
83
+ const headers = seriesAreIndicators
84
+ ? buildHeaders(asStringArray(d.indicatorNameList), indicators, indicators.length)
85
+ : buildHeaders(asStringArray(d.securityNameList), securityCode, securityCode.length);
86
+ const list = dates.map((date, k) => {
87
+ const row = { date };
88
+ for (let i = 0; i < headers.length; i++) {
89
+ row[headers[i]] = rowOf(d.values, i)?.[k];
90
+ }
91
+ return row;
92
+ });
93
+ return { list, total: list.length };
94
+ }
@@ -141,7 +141,10 @@ function pickListForStreaming(value) {
141
141
  }
142
142
  function csvEscape(value) {
143
143
  let out = value;
144
- if (/^[=+\-@\t\r]/.test(out))
144
+ // Formula-injection guard, but don't mangle legitimate numbers: a leading
145
+ // -/+ only needs escaping when the cell isn't a finite number (e.g. "-1+cmd"),
146
+ // so values like "-3.5" stay numeric for Excel/pandas.
147
+ if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
145
148
  out = "'" + out;
146
149
  if (/[",\n]/.test(out))
147
150
  return `"${out.replaceAll("\"", "\"\"")}"`;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.18.0";
2
+ export const CLI_VERSION = "0.20.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {