invesresearch 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhanglunet (InvesResearch)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # invesresearch
2
+
3
+ 命令行里的投研工作台。行情、财务、可比、对话式研判、卫星互联网产业信号 —— 全部来自
4
+ [oaf.world](https://oaf.world) 的公开只读 API。
5
+
6
+ **零依赖、无需注册、无需密钥。**
7
+
8
+ ```bash
9
+ npx invesresearch quote NVDA
10
+ ```
11
+
12
+ ```
13
+ 标的 US:NVDA
14
+ 名称 NVIDIA Corporation · 英伟达
15
+ 价格 206.84
16
+ 涨跌% -0.5529
17
+ 币种 USD
18
+ 总股本 24,221,000,000
19
+ 数据源 yfinance
20
+ 时间 2026-07-25T11:05:28Z
21
+ ```
22
+
23
+ ## 安装
24
+
25
+ 不装也能用(推荐):
26
+
27
+ ```bash
28
+ npx invesresearch <命令>
29
+ ```
30
+
31
+ 想常驻就全局装,`invesresearch` 和短别名 `ires` 都可用:
32
+
33
+ ```bash
34
+ npm install -g invesresearch
35
+ ires quote 0700.HK
36
+ ```
37
+
38
+ 要求 **Node.js ≥ 18**(用到内置 `fetch`)。
39
+
40
+ ## 标的代码怎么写
41
+
42
+ 四种写法都认,内部统一映射到后端 uid:
43
+
44
+ | 你输入 | 解析为 | 说明 |
45
+ | --- | --- | --- |
46
+ | `NVDA` `brk.b` | `US:NVDA` `US:BRK.B` | 美股,纯字母 |
47
+ | `600519` | `SH:600519` | A 股,6 开头 → 沪 |
48
+ | `000001` `300750` | `SZ:000001` `SZ:300750` | A 股,0/3 开头 → 深 |
49
+ | `700` `0700.HK` `09988.HK` | `HK:00700` `HK:00700` `HK:09988` | 港股,自动补零到 5 位 |
50
+ | `600519.SS` `300750.SZ` | `SH:600519` `SZ:300750` | Yahoo 后缀写法 |
51
+ | `US:NVDA` `SH:600519` | 原样 | 直接给 uid 也行 |
52
+
53
+ 不知道代码就先搜:
54
+
55
+ ```bash
56
+ npx invesresearch search 宁德
57
+ ```
58
+
59
+ ```
60
+ 代码 名称 市场
61
+ SZ:300750 宁德时代 SZ
62
+ HK:03750 宁德时代 HK
63
+ ```
64
+
65
+ ## 命令
66
+
67
+ ### 行情与基本面
68
+
69
+ | 命令 | 说明 |
70
+ | --- | --- |
71
+ | `quote <代码>` | 实时报价(价格/涨跌/总股本) |
72
+ | `search <关键词> [--limit N]` | 全市场名称/代码模糊搜索 |
73
+ | `news <代码> [--limit N] [--zh]` | 个股新闻,`--zh` 附中文标题翻译 |
74
+ | `financials <代码> [--period 2025A] [--cross-check]` | 财务指标,可多源交叉校验 |
75
+ | `history <代码> [--years 5] [--quarter]` | 历年(或历季)财务趋势 |
76
+ | `peers <代码> [--period 2025A]` | 可比公司 PE/PB/PS |
77
+ | `profile <代码> [--period 2025A]` | 公司画像 |
78
+ | `bars <代码> [--days 30]` | 日线 K 线(`--days` 最小 5) |
79
+ | `indices` | 主要指数行情 |
80
+ | `industries` | 已覆盖的行业专题列表 |
81
+ | `industry <key>` | 行业专题详情(成分股 + 财务聚合 + 产业链) |
82
+ | `pulse [key]` | 行业异动预警,默认 `satellite` |
83
+
84
+ ### 研究(模型生成,输出带免责声明)
85
+
86
+ | 命令 | 说明 |
87
+ | --- | --- |
88
+ | `ask <问题…>` | 对话式投研问答 |
89
+ | `thesis <代码>` | 多空研判 + 护城河 |
90
+ | `compare <代码A> <代码B> […] [--cross-check]` | 同口径多标的对比 |
91
+
92
+ ```bash
93
+ npx invesresearch ask "英伟达的护城河在哪"
94
+ npx invesresearch compare NVDA AMD
95
+ ```
96
+
97
+ ### 卫星互联网产业 agent(只读)
98
+
99
+ | 命令 | 说明 |
100
+ | --- | --- |
101
+ | `events [--limit N] [--thread 主线]` | 产业事件流 |
102
+ | `alerts [--window 7] [--limit N]` | 风险预警(含证伪触发器) |
103
+ | `weekly [--window 7]` | 周度决策报告(markdown) |
104
+ | `companies [--thread 主线]` | 产业链公司卡片 |
105
+ | `thesis-state` | 各主线 thesis 实时评分 |
106
+ | `sources` | 数据来源账本 |
107
+
108
+ ## 全局选项
109
+
110
+ | 选项 | 说明 |
111
+ | --- | --- |
112
+ | `--json`, `-j` | 输出原始 JSON,可管道给 `jq` |
113
+ | `--api <url>` | 覆盖行情 API 端点(默认 `https://api.oaf.world`,亦可用环境变量 `IR_API_BASE`) |
114
+ | `--agent <url>` | 覆盖 agent 端点(默认 `https://agent.oaf.world`,亦可用 `IR_AGENT_BASE`) |
115
+ | `--timeout <ms>` | 请求超时。默认 30s,研究类命令 180s |
116
+ | `--help`, `-h` | 帮助 |
117
+ | `--version`, `-v` | 版本 |
118
+
119
+ 管道用法:
120
+
121
+ ```bash
122
+ npx invesresearch quote 0700.HK --json | jq .price
123
+ npx invesresearch peers NVDA --json | jq -r '.rows[] | "\(.name)\t\(.pe)"'
124
+ npx invesresearch weekly > 周报.md
125
+ ```
126
+
127
+ 退出码:`0` 成功 · `1` 请求失败(错误进 stderr,stdout 不产出任何数据)· `2` 用法错误。
128
+
129
+ ## 数据边界(诚实说明)
130
+
131
+ - **数字来自数据源,不由本工具计算。** CLI 只做参数映射与排版,任何指标、估值、涨跌都是后端返回的原值。
132
+ - **缺数显式降级。** 取不到的字段一律显示 `—`,不会填 0、不会省略、更不会编。
133
+ - **搜索覆盖不对称。** A 股中文名可全量模糊搜;港股/美股认英文、拼音、代码,中文名只覆盖精选清单 —— 港美股搜不到时请改用代码。
134
+ - **`ask` / `thesis` / `compare` 的叙述由模型生成**,其中的数字仍来自确定性计算。输出末尾强制带免责声明。
135
+ - **`compare` 默认不做多源交叉校验**(实测 2 个标的开启后 >200s)。需要时加 `--cross-check`。
136
+ - **预警不命中就是不报警。** `alerts` / `pulse` 返回空表示规则未触发,不是故障。
137
+
138
+ ## 隐私与凭证
139
+
140
+ 本工具**不接受、不读取、不存储任何 token 或密钥**,也从不发送 `Authorization` 头 —— 目标接口本就是公开只读服务。除了你输入的查询参数,不会向任何第三方发送数据,也没有遥测。
141
+
142
+ ## 免责声明
143
+
144
+ 本工具输出仅供研究参考,**不构成投资建议**。投资有风险,决策需谨慎。
145
+
146
+ ## 开发
147
+
148
+ ```bash
149
+ npm test # node --test,零 devDependencies
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT(见 [LICENSE](./LICENSE))。注意:仓库其余部分是 Apache-2.0,本 CLI 单独采用更宽松的 MIT。
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/main.js';
3
+
4
+ const major = Number(process.versions.node.split('.')[0]);
5
+ if (major < 18) {
6
+ process.stderr.write(`invesresearch 需要 Node.js >= 18(当前 ${process.versions.node})\n`);
7
+ process.exit(1);
8
+ }
9
+
10
+ // 管道给 head/less 时下游会提前关闭 stdout —— 默认行为是抛未捕获的 EPIPE 并打一屏堆栈。
11
+ // 对 CLI 来说 `… | head -5` 是常规用法, 静默按 0 退出才对。
12
+ for (const s of [process.stdout, process.stderr]) {
13
+ s.on('error', (e) => { if (e && e.code === 'EPIPE') process.exit(0); });
14
+ }
15
+
16
+ run(process.argv.slice(2)).then((code) => { process.exitCode = code; });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "invesresearch",
3
+ "version": "0.1.0",
4
+ "description": "InvesResearch CLI — quotes, fundamentals, research and satellite-industry signals from the public oaf.world APIs. Zero dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "invesresearch": "bin/invesresearch.mjs",
8
+ "ires": "bin/invesresearch.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./src/main.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test",
24
+ "prepublishOnly": "node --test"
25
+ },
26
+ "keywords": [
27
+ "stocks",
28
+ "quotes",
29
+ "finance",
30
+ "research",
31
+ "cli",
32
+ "a-share",
33
+ "hk-stocks",
34
+ "satellite"
35
+ ],
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/zhanglunet/InvesResearch.git",
40
+ "directory": "cli"
41
+ },
42
+ "homepage": "https://oaf.world",
43
+ "dependencies": {},
44
+ "devDependencies": {}
45
+ }
package/src/args.js ADDED
@@ -0,0 +1,32 @@
1
+ // 极简 argv 解析(零依赖)。只支持 --long[=value] 与 -h/-v/-j 三个短别名 —— 够用即可。
2
+ const SHORT = { h: 'help', v: 'version', j: 'json' };
3
+
4
+ export function parseArgv(argv) {
5
+ const out = { cmd: null, args: [], flags: {} };
6
+ let rest = false;
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const tok = argv[i];
9
+ if (rest) { out.args.push(tok); continue; }
10
+ if (tok === '--') { rest = true; continue; }
11
+
12
+ if (tok.startsWith('--')) {
13
+ const body = tok.slice(2);
14
+ const eq = body.indexOf('=');
15
+ if (eq > 0) { out.flags[body.slice(0, eq)] = body.slice(eq + 1); continue; }
16
+ const next = argv[i + 1];
17
+ // 后面没东西 / 是 -- / 是另一个 flag ⇒ 当布尔开关
18
+ if (next === undefined || next === '--' || next.startsWith('-')) out.flags[body] = true;
19
+ else { out.flags[body] = next; i++; }
20
+ continue;
21
+ }
22
+
23
+ if (tok.startsWith('-') && tok.length === 2 && SHORT[tok[1]]) {
24
+ out.flags[SHORT[tok[1]]] = true;
25
+ continue;
26
+ }
27
+
28
+ if (out.cmd === null) out.cmd = tok;
29
+ else out.args.push(tok);
30
+ }
31
+ return out;
32
+ }
@@ -0,0 +1,299 @@
1
+ import { normalizeUid } from './uid.js';
2
+ import { fmt, kv, table, bullets, truncate } from './render.js';
3
+
4
+ // 与后端 ThesisBody / AskBody 的默认假设保持一致(改这里前先对齐后端)。
5
+ export const DEFAULT_ASSUMPTIONS = {
6
+ revenue_growth: 0.08, fcf_margin: 0.22, wacc: 0.09, terminal_growth: 0.025, forecast_years: 10,
7
+ };
8
+
9
+ // /financials 与 /financials/history 返回同一套宽行(一期一行), 共用列定义。
10
+ const FIN_COLS = [
11
+ ['period', '期间'], ['revenue', '营收'], ['gross_profit', '毛利'],
12
+ ['operating_income', '经营利润'], ['net_income', '净利'],
13
+ ['operating_cash_flow', '经营现金流'], ['currency', '币种'], ['source', '来源'],
14
+ ];
15
+
16
+ // 预警条目有三种形态: 事件(title)、证伪触发器(reason)、板块价格异动(median_move_pct)。
17
+ // 取不到人话就明说"无描述", 绝不把原始 JSON 糊到终端上。
18
+ function alertText(a) {
19
+ if (!a || typeof a !== 'object') return String(a ?? '');
20
+ if (a.title_zh || a.title) return a.title_zh || a.title;
21
+ if (a.reason) return a.reason;
22
+ if (a.message || a.text) return a.message || a.text;
23
+ if (a.median_move_pct !== undefined && a.median_move_pct !== null) {
24
+ const cover = a.covered !== undefined && a.total !== undefined ? `(覆盖 ${a.covered}/${a.total})` : '';
25
+ return `板块价格异动: 中位 ${fmt(a.median_move_pct)}%${cover}`;
26
+ }
27
+ return '(该条无文字描述,用 --json 看原始字段)';
28
+ }
29
+
30
+ const needUid = (args) => normalizeUid(args[0] ?? '');
31
+ const joinQ = (args, what) => {
32
+ const q = args.join(' ').trim();
33
+ if (!q) throw new Error(`需要一个${what}`);
34
+ return q;
35
+ };
36
+
37
+ // 命令表: 一条命令 = 一行。host 决定打哪个服务, build 只做参数映射, render 只做排版。
38
+ // agent host 的路径必须落在生产网关的只读 GET 白名单内(见 web-backend/deploy/Caddyfile)。
39
+ export const COMMANDS = {
40
+ // ---------------------------- 行情 / 基本面 ---------------------------- //
41
+ quote: {
42
+ host: 'api', path: '/quote',
43
+ usage: 'quote <代码>', desc: '实时报价(价格/涨跌/总股本)',
44
+ build: (args) => ({ query: { uid: needUid(args) } }),
45
+ render: (d) => kv([
46
+ ['标的', d.uid], ['名称', d.name], ['价格', d.price], ['涨跌%', d.change_pct],
47
+ ['币种', d.currency], ['总股本', d.shares_outstanding], ['数据源', d.source], ['时间', d.as_of],
48
+ ]),
49
+ },
50
+ search: {
51
+ host: 'api', path: '/search', timeoutMs: 60000,
52
+ usage: 'search <关键词> [--limit N]', desc: '全市场名称/代码模糊搜索',
53
+ build: (args, flags) => ({ query: { q: joinQ(args, '关键词'), limit: flags.limit ?? 8 } }),
54
+ render: (d) => table(d.results || [], [['uid', '代码'], ['name', '名称'], ['market', '市场']]),
55
+ },
56
+ news: {
57
+ host: 'api', path: '/news',
58
+ usage: 'news <代码> [--limit N] [--zh]', desc: '个股新闻(--zh 附中文标题翻译)',
59
+ build: (args, flags) => ({
60
+ query: { uid: needUid(args), limit: flags.limit ?? 6, lang: flags.zh ? 'zh' : '' },
61
+ }),
62
+ render: (rows) => table((rows || []).map((n) => ({
63
+ published_at: (n.published_at || '').slice(0, 10),
64
+ title: truncate(n.title_zh || n.title, 60),
65
+ publisher: n.publisher,
66
+ })), [['published_at', '日期'], ['title', '标题'], ['publisher', '来源']]),
67
+ },
68
+ financials: {
69
+ host: 'api', path: '/financials',
70
+ usage: 'financials <代码> [--period 2025A] [--cross-check]', desc: '财务指标(可多源交叉校验)',
71
+ build: (args, flags) => ({
72
+ query: {
73
+ uid: needUid(args), period: flags.period ?? '2025A',
74
+ cross_check: flags['cross-check'] ? 'true' : '',
75
+ },
76
+ }),
77
+ render: (rows) => table(rows || [], FIN_COLS),
78
+ },
79
+ history: {
80
+ host: 'api', path: '/financials/history',
81
+ usage: 'history <代码> [--years 5] [--quarter]', desc: '历年(或历季)财务趋势',
82
+ build: (args, flags) => ({
83
+ query: {
84
+ uid: needUid(args), years: flags.years ?? 5,
85
+ granularity: flags.quarter ? 'quarter' : 'annual',
86
+ },
87
+ }),
88
+ render: (rows) => table(rows || [], FIN_COLS),
89
+ },
90
+ peers: {
91
+ host: 'api', path: '/peers', timeoutMs: 60000,
92
+ usage: 'peers <代码> [--period 2025A]', desc: '可比公司(PE/PB/PS 同口径)',
93
+ build: (args, flags) => ({ query: { uid: needUid(args), period: flags.period ?? '2025A' } }),
94
+ render: (d) => table((d && d.rows) || (Array.isArray(d) ? d : []),
95
+ [['uid', '代码'], ['name', '名称'], ['pe', 'PE'], ['pb', 'PB'], ['ps', 'PS']]),
96
+ },
97
+ profile: {
98
+ host: 'api', path: '/company-profile',
99
+ usage: 'profile <代码> [--period 2025A]', desc: '公司画像(业务/规模/关键指标)',
100
+ build: (args, flags) => ({ query: { uid: needUid(args), period: flags.period ?? '2025A' } }),
101
+ render: (d) => kv(Object.entries(d || {}).filter(([, v]) => v === null || typeof v !== 'object')),
102
+ },
103
+ bars: {
104
+ host: 'api', path: '/bars',
105
+ usage: 'bars <代码> [--days 30]', desc: '日线 K 线',
106
+ build: (args, flags) => ({ query: { uid: needUid(args), days: flags.days ?? 30 } }),
107
+ render: (rows) => table(rows || [], [['date', '日期'], ['open', '开'], ['high', '高'],
108
+ ['low', '低'], ['close', '收'], ['volume', '量']]),
109
+ },
110
+ indices: {
111
+ host: 'api', path: '/indices',
112
+ usage: 'indices', desc: '主要指数行情',
113
+ build: () => ({}),
114
+ render: (rows) => table(rows || [], [['name', '指数'], ['price', '点位'], ['change_pct', '涨跌%']]),
115
+ },
116
+ industries: {
117
+ host: 'api', path: '/industries',
118
+ usage: 'industries', desc: '已覆盖的行业专题列表',
119
+ build: () => ({}),
120
+ render: (rows) => table(rows || [], [['key', 'key'], ['name', '名称'], ['count', '标的数']]),
121
+ },
122
+ industry: {
123
+ host: 'api', path: '/industry', timeoutMs: 60000, llm: false,
124
+ usage: 'industry <key>', desc: '行业专题详情(用 industries 查 key)',
125
+ build: (args) => ({ query: { key: joinQ(args, '行业 key') } }),
126
+ render: (d) => {
127
+ const cov = d.coverage || {};
128
+ const agg = d.agg || {};
129
+ const parts = [
130
+ `${d.name || d.key}(${d.key})· 成分覆盖 ${fmt(cov.got)}/${fmt(cov.total)}`,
131
+ '',
132
+ kv([['合计营收', agg.total_revenue], ['合计净利', agg.total_net_income],
133
+ ['营收 CAGR', agg.revenue_cagr], ['中位 PE', agg.median_pe]]),
134
+ '',
135
+ '成分股',
136
+ table(d.members || [], [['uid', '代码'], ['name', '名称'],
137
+ ['market_cap', '市值'], ['pe', 'PE'], ['roe', 'ROE']]),
138
+ ];
139
+ if (d.chain) {
140
+ parts.push('', '产业链');
141
+ for (const [seg, label] of [['upstream', '上游'], ['mid', '中游'], ['downstream', '下游']]) {
142
+ if (d.chain[seg]) parts.push(` ${label}: ${fmt(d.chain[seg])}`);
143
+ }
144
+ }
145
+ if (d.disclaimer) parts.push('', '⚠️ ' + d.disclaimer);
146
+ return parts.join('\n');
147
+ },
148
+ },
149
+ pulse: {
150
+ host: 'api', path: '/sector-pulse', timeoutMs: 60000,
151
+ usage: 'pulse [key]', desc: '行业异动预警(默认 satellite)',
152
+ build: (args) => ({ query: { key: args[0] || 'satellite' } }),
153
+ render: (d) => {
154
+ const rows = (d && d.alerts) || [];
155
+ if (!rows.length) return '近期无异动命中 —— 规则未触发即不报警(非故障)。';
156
+ return table(rows.map((a) => ({
157
+ kind: a.kind, severity: a.severity, text: truncate(alertText(a), 70),
158
+ })), [['kind', '类型'], ['severity', '级别'], ['text', '内容']]);
159
+ },
160
+ },
161
+
162
+ // ------------------------------- 研究(LLM) ------------------------------ //
163
+ ask: {
164
+ host: 'api', method: 'POST', path: '/ask', llm: true, timeoutMs: 180000,
165
+ usage: 'ask <问题…>', desc: '对话式投研问答',
166
+ build: (args) => ({
167
+ body: {
168
+ question: joinQ(args, '问题'), context: [],
169
+ assumptions: DEFAULT_ASSUMPTIONS, period: '2025A', cross_check: true,
170
+ },
171
+ }),
172
+ render: (d) => d.answer || d.narrative || JSON.stringify(d, null, 2),
173
+ },
174
+ thesis: {
175
+ host: 'api', method: 'POST', path: '/thesis', llm: true, timeoutMs: 180000,
176
+ usage: 'thesis <代码>', desc: '多空研判 + 护城河',
177
+ build: (args) => ({
178
+ body: {
179
+ uid: needUid(args), assumptions: DEFAULT_ASSUMPTIONS, period: '2025A', cross_check: true,
180
+ },
181
+ }),
182
+ // 返回是结构化的 moat/bull/bear/verdict 数组, 不是一整段 narrative —— 自己排成研判卡。
183
+ render: (d) => {
184
+ if (!d || typeof d !== 'object') return String(d ?? '');
185
+ const parts = [
186
+ `${d.name || ''}(${d.uid || ''}) — 多空研判`,
187
+ kv([['确定性评级', d.rating], ['目标价', d.target_price],
188
+ ['相对现价上行%', d.upside_pct], ['币种', d.currency]]),
189
+ ];
190
+ const sect = (title, items, fmtItem) => {
191
+ if (!items || !items.length) return;
192
+ parts.push('', title, bullets(items.map(fmtItem)));
193
+ };
194
+ sect('护城河', d.moat, (m) => (m.label ? `${m.label}:${m.point}` : fmt(m)));
195
+ sect('看多', d.bull, (s) => fmt(s));
196
+ sect('看空', d.bear, (s) => fmt(s));
197
+ if (d.verdict) parts.push('', `研判:${d.verdict}`);
198
+ return parts.join('\n');
199
+ },
200
+ },
201
+ compare: {
202
+ host: 'api', method: 'POST', path: '/compare', llm: true, timeoutMs: 180000,
203
+ usage: 'compare <代码A> <代码B> [更多…] [--cross-check]', desc: '同口径多标的对比',
204
+ // cross_check 对多标的极慢(实测 2 个标的 >200s, 关掉 4.9s), 故默认关、要多源校验再显式开。
205
+ build: (args, flags) => {
206
+ if (args.length < 2) throw new Error('需要至少两个标的代码');
207
+ return {
208
+ body: {
209
+ uids: args.map(normalizeUid), assumptions: DEFAULT_ASSUMPTIONS,
210
+ period: flags.period ?? '2025A', cross_check: Boolean(flags['cross-check']),
211
+ },
212
+ };
213
+ },
214
+ render: (d) => {
215
+ const rows = (d.items || []).map((it) => ({
216
+ uid: it.uid,
217
+ name: (it.profile && it.profile.name) || '',
218
+ price: it.valuation && it.valuation.current_price,
219
+ value: it.valuation && it.valuation.intrinsic_value_per_share,
220
+ upside_pct: it.valuation && it.valuation.upside_pct,
221
+ rating: it.rating,
222
+ }));
223
+ const parts = [table(rows, [['uid', '代码'], ['name', '名称'], ['price', '现价'],
224
+ ['value', 'DCF 每股价值'], ['upside_pct', '上行%'], ['rating', '评级']])];
225
+ const errs = Object.entries(d.errors || {});
226
+ if (errs.length) parts.push('', '取数失败(显式列出, 不静默跳过):',
227
+ bullets(errs.map(([uid, msg]) => `${uid}: ${fmt(msg)}`)));
228
+ if (d.comparison) parts.push('', d.comparison);
229
+ return parts.join('\n');
230
+ },
231
+ },
232
+
233
+ // --------------------- 卫星互联网 agent(只读公开) ---------------------- //
234
+ events: {
235
+ host: 'agent', path: '/events',
236
+ usage: 'events [--limit N] [--thread 主线]', desc: '产业事件流',
237
+ build: (args, flags) => ({ query: { limit: flags.limit ?? 20, thread: flags.thread ?? '' } }),
238
+ render: (rows) => table((rows || []).map((e) => ({
239
+ date: (e.happened_at || e.created_at || '').slice(0, 10),
240
+ title: truncate(e.title, 60), source: e.source,
241
+ })), [['date', '日期'], ['title', '标题'], ['source', '来源']]),
242
+ },
243
+ alerts: {
244
+ host: 'agent', path: '/alerts',
245
+ usage: 'alerts [--window 7] [--limit N]', desc: '风险预警(含证伪触发器)',
246
+ build: (args, flags) => ({
247
+ query: { window: flags.window ?? 7, limit: flags.limit ?? 20, include_triggers: 1 },
248
+ }),
249
+ render: (rows) => {
250
+ const list = Array.isArray(rows) ? rows : (rows && rows.alerts) || [];
251
+ if (!list.length) return '窗口内无风险事件命中 —— 规则未触发即不报警(非故障;事件流仍在实时更新)。';
252
+ return bullets(list.map((a) => {
253
+ const head = [a.thread, a.severity && `[${a.severity}]`].filter(Boolean).join(' ');
254
+ return `${head} ${truncate(alertText(a), 90)}`.trim();
255
+ }));
256
+ },
257
+ },
258
+ weekly: {
259
+ host: 'agent', path: '/decision/weekly',
260
+ usage: 'weekly [--window 7]', desc: '卫星产业周度决策报告(markdown)',
261
+ build: (args, flags) => ({ query: { window: flags.window ?? 7, format: 'md' } }),
262
+ // format=md 时后端回的是 {"markdown": "..."} 包装, 不是裸文本 —— 直接 stringify 会满屏 \n 转义。
263
+ render: (d) => {
264
+ if (typeof d === 'string') return d;
265
+ if (d && typeof d.markdown === 'string') return d.markdown;
266
+ return JSON.stringify(d, null, 2);
267
+ },
268
+ },
269
+ companies: {
270
+ host: 'agent', path: '/companies',
271
+ usage: 'companies [--thread 主线]', desc: '产业链公司卡片',
272
+ build: (args, flags) => ({ query: { thread: flags.thread ?? '' } }),
273
+ render: (rows) => table((rows || []).map((c) => ({
274
+ name: c.name, thread: c.thread, score: c.score,
275
+ moat: truncate(c.moat, 28), risk: truncate(c.risk, 28),
276
+ })), [['name', '公司'], ['thread', '主线'], ['score', '评分'],
277
+ ['moat', '护城河'], ['risk', '风险']]),
278
+ },
279
+ 'thesis-state': {
280
+ host: 'agent', path: '/thesis/state',
281
+ usage: 'thesis-state', desc: '各主线 thesis 实时评分',
282
+ build: () => ({}),
283
+ render: (d) => {
284
+ const rows = Array.isArray(d) ? d : Object.entries(d || {}).map(([thread, v]) =>
285
+ (v && typeof v === 'object' ? { thread, ...v } : { thread, score: v }));
286
+ return table(rows, [['thread', '主线'], ['score', '评分'], ['updated_at', '更新时间']]);
287
+ },
288
+ },
289
+ sources: {
290
+ host: 'agent', path: '/sources/ledger',
291
+ usage: 'sources', desc: '数据来源账本(各源事件数)',
292
+ build: () => ({}),
293
+ render: (d) => {
294
+ const rows = (d && d.by_source) || (Array.isArray(d) ? d : []);
295
+ const head = d && d.total !== undefined ? `全库事件总数: ${fmt(d.total)}\n\n` : '';
296
+ return head + table(rows, [['source', '来源'], ['count', '条数'], ['category', '类别']]);
297
+ },
298
+ },
299
+ };
package/src/http.js ADDED
@@ -0,0 +1,65 @@
1
+ // 唯一网络出口。fetchImpl 依赖注入 => 离线可测。
2
+ // 凭证不在这一层: 目标 API 是公开只读服务, 本客户端不发任何 Authorization 头。
3
+ export const UA = 'invesresearch-cli';
4
+
5
+ export function buildUrl(base, path, query = {}) {
6
+ const u = new URL(path, base.endsWith('/') ? base : base + '/');
7
+ for (const [k, v] of Object.entries(query)) {
8
+ if (v === undefined || v === null || v === '') continue;
9
+ u.searchParams.set(k, String(v));
10
+ }
11
+ return u.toString();
12
+ }
13
+
14
+ // FastAPI 的 422 detail 是一串 pydantic 校验对象, 原样 stringify 出来没法读。
15
+ // 抽成「参数 xxx: 说明」的人话; 其它形态原样保留。
16
+ export function formatDetail(detail) {
17
+ if (typeof detail === 'string') return detail;
18
+ if (Array.isArray(detail) && detail.length && detail.every((d) => d && d.msg)) {
19
+ return detail.map((d) => {
20
+ const field = Array.isArray(d.loc) ? d.loc[d.loc.length - 1] : d.loc;
21
+ return field ? `参数 ${field}: ${d.msg}` : d.msg;
22
+ }).join('; ');
23
+ }
24
+ return JSON.stringify(detail);
25
+ }
26
+
27
+ export async function request({
28
+ base, method = 'GET', path, query = {}, body = null,
29
+ fetchImpl = globalThis.fetch, timeoutMs = 20000,
30
+ }) {
31
+ const url = buildUrl(base, path, query);
32
+ const headers = { Accept: 'application/json', 'User-Agent': UA };
33
+ const opt = { method, headers };
34
+ if (body != null) {
35
+ headers['Content-Type'] = 'application/json';
36
+ opt.body = JSON.stringify(body);
37
+ }
38
+ if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) opt.signal = AbortSignal.timeout(timeoutMs);
39
+
40
+ let res;
41
+ try {
42
+ res = await fetchImpl(url, opt);
43
+ } catch (e) {
44
+ if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
45
+ throw new Error(`请求超时(${timeoutMs}ms): ${url}`);
46
+ }
47
+ throw e;
48
+ }
49
+
50
+ const text = await res.text();
51
+ if (!res.ok) {
52
+ let detail = text.slice(0, 200);
53
+ try {
54
+ const j = JSON.parse(text);
55
+ if (j && j.detail !== undefined) detail = formatDetail(j.detail);
56
+ } catch { /* 非 JSON 错误体, 用原文前 200 字 */ }
57
+ throw new Error(`HTTP ${res.status} ${method} ${url} — ${detail}`);
58
+ }
59
+ if (!text) return null;
60
+ try {
61
+ return JSON.parse(text);
62
+ } catch {
63
+ return text; // /decision/weekly?format=md 之类的非 JSON 响应原样返回
64
+ }
65
+ }
package/src/main.js ADDED
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { parseArgv } from './args.js';
4
+ import { request } from './http.js';
5
+ import { COMMANDS } from './commands.js';
6
+ import { DISCLAIMER } from './render.js';
7
+
8
+ // 生产端点(公开只读, 无需任何凭证)。可用 --api/--agent 或 IR_API_BASE/IR_AGENT_BASE 覆盖。
9
+ export const API_BASE = 'https://api.oaf.world';
10
+ export const AGENT_BASE = 'https://agent.oaf.world';
11
+
12
+ // 30s 给普通端点(实测 /peers 冷启动约 11s、/sector-pulse 约 16s)。LLM 端点在命令表里
13
+ // 自带更长的 timeoutMs —— 生成一篇研判本来就要几十秒。
14
+ export const DEFAULT_TIMEOUT_MS = 30000;
15
+
16
+ const pkg = () => JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
17
+
18
+ export function helpText() {
19
+ const lines = [
20
+ 'invesresearch — InvesResearch 命令行客户端',
21
+ '数据来自 oaf.world 公开只读 API, 无需注册、无需密钥。',
22
+ '',
23
+ '用法: invesresearch <命令> [参数] [选项] (短别名: ires)',
24
+ '',
25
+ '命令:',
26
+ ];
27
+ const w = Math.max(...Object.values(COMMANDS).map((c) => c.usage.length));
28
+ for (const c of Object.values(COMMANDS)) lines.push(` ${c.usage.padEnd(w)} ${c.desc}`);
29
+ lines.push(
30
+ '',
31
+ '代码写法: NVDA / 600519 / 0700.HK / 09988.HK / US:NVDA / SH:600519 都认',
32
+ '',
33
+ '全局选项:',
34
+ ' --json, -j 输出原始 JSON(可管道给 jq)',
35
+ ` --api <url> 覆盖行情 API 端点(默认 ${API_BASE}, 亦可用 IR_API_BASE)`,
36
+ ` --agent <url> 覆盖 agent API 端点(默认 ${AGENT_BASE}, 亦可用 IR_AGENT_BASE)`,
37
+ ' --timeout <ms> 请求超时(默认 20000)',
38
+ ' --help, -h 显示本帮助',
39
+ ' --version, -v 显示版本',
40
+ '',
41
+ '示例:',
42
+ ' npx invesresearch quote NVDA',
43
+ ' npx invesresearch search 宁德',
44
+ ' npx invesresearch ask "英伟达的护城河在哪"',
45
+ ' npx invesresearch alerts --window 30',
46
+ ' npx invesresearch quote 0700.HK --json | jq .price',
47
+ '',
48
+ DISCLAIMER,
49
+ );
50
+ return lines.join('\n');
51
+ }
52
+
53
+ // flag 写成 `--api`(裸开关)时值是 true —— 那不是一个 URL, 回落默认值。
54
+ const pickBase = (flagVal, envVal, dflt) =>
55
+ (typeof flagVal === 'string' && flagVal ? flagVal : (envVal || dflt));
56
+
57
+ export async function run(argv, io = {}) {
58
+ const out = io.out || ((s) => process.stdout.write(s + '\n'));
59
+ const err = io.err || ((s) => process.stderr.write(s + '\n'));
60
+ const fetchImpl = io.fetchImpl || globalThis.fetch;
61
+ const env = io.env || process.env;
62
+
63
+ const { cmd, args, flags } = parseArgv(argv);
64
+
65
+ if (flags.version) { out(pkg().version); return 0; }
66
+ if (!cmd || flags.help) { out(helpText()); return 0; }
67
+
68
+ const spec = COMMANDS[cmd];
69
+ if (!spec) { err(`未知命令: ${cmd}\n`); err(helpText()); return 2; }
70
+
71
+ let built;
72
+ try {
73
+ built = spec.build(args, flags) || {};
74
+ } catch (e) {
75
+ err(`${e.message}\n用法: ${spec.usage}`);
76
+ return 2;
77
+ }
78
+
79
+ const base = spec.host === 'agent'
80
+ ? pickBase(flags.agent, env.IR_AGENT_BASE, AGENT_BASE)
81
+ : pickBase(flags.api, env.IR_API_BASE, API_BASE);
82
+
83
+ try {
84
+ const data = await request({
85
+ base,
86
+ method: spec.method || 'GET',
87
+ path: built.path || spec.path,
88
+ query: built.query || {},
89
+ body: built.body || null,
90
+ fetchImpl,
91
+ timeoutMs: Number(flags.timeout) || spec.timeoutMs || DEFAULT_TIMEOUT_MS,
92
+ });
93
+ if (flags.json) { out(JSON.stringify(data, null, 2)); return 0; }
94
+ out(spec.render(data));
95
+ // 免责声明是硬性要求。后端自带 disclaimer 时以它为准(口径归后端), 否则用本地兜底 —— 二选一, 只出一条。
96
+ if (spec.llm) {
97
+ const own = data && typeof data.disclaimer === 'string' && data.disclaimer.trim();
98
+ out('\n⚠️ ' + (own || DISCLAIMER.replace(/^⚠️\s*/, '')));
99
+ }
100
+ return 0;
101
+ } catch (e) {
102
+ err(e.message);
103
+ return 1;
104
+ }
105
+ }
package/src/render.js ADDED
@@ -0,0 +1,40 @@
1
+ // 纯格式化。**不做任何计算** —— 数字一律来自 API, 这里只排版。
2
+ export const DISCLAIMER = '⚠️ 以上内容由公开数据与模型生成,仅供研究参考,不构成投资建议。';
3
+
4
+ // 中日韩宽字符按 2 列宽算, 否则中文表格会歪。
5
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹯＀-⦆¢-₩]/;
6
+ const width = (s) => [...String(s)].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0);
7
+ const pad = (s, w) => String(s) + ' '.repeat(Math.max(0, w - width(s)));
8
+
9
+ export function fmt(v) {
10
+ if (v === null || v === undefined || v === '') return '—';
11
+ if (typeof v === 'number') {
12
+ if (!Number.isFinite(v)) return '—';
13
+ return v.toLocaleString('en-US', { maximumFractionDigits: 4 });
14
+ }
15
+ if (Array.isArray(v)) return v.length ? v.map(fmt).join(', ') : '—';
16
+ if (typeof v === 'object') return JSON.stringify(v);
17
+ return String(v);
18
+ }
19
+
20
+ export function kv(pairs) {
21
+ const w = Math.max(0, ...pairs.map(([k]) => width(k)));
22
+ return pairs.map(([k, v]) => `${pad(k, w)} ${fmt(v)}`).join('\n');
23
+ }
24
+
25
+ export function table(rows, cols) {
26
+ if (!rows || !rows.length) return '(无数据)';
27
+ const widths = cols.map(([key, header]) =>
28
+ Math.max(width(header), ...rows.map((r) => width(fmt(r[key])))));
29
+ const line = (cells) => cells.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
30
+ return [line(cols.map(([, h]) => h)), ...rows.map((r) => line(cols.map(([k]) => fmt(r[k]))))].join('\n');
31
+ }
32
+
33
+ export function bullets(items) {
34
+ return (items || []).map((s) => `· ${s}`).join('\n');
35
+ }
36
+
37
+ export function truncate(s, n) {
38
+ const t = String(s ?? '');
39
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
40
+ }
package/src/uid.js ADDED
@@ -0,0 +1,31 @@
1
+ // 用户友好写法 → 后端 uid。纯字符串映射, 不查网络、不猜价格。
2
+ const PREFIXES = new Set(['US', 'HK', 'SH', 'SZ']);
3
+
4
+ export function normalizeUid(input) {
5
+ const s = String(input ?? '').trim();
6
+ if (!s) throw new Error('需要一个标的代码, 例如 NVDA / 600519 / 0700.HK');
7
+
8
+ const colon = s.indexOf(':');
9
+ if (colon > 0) {
10
+ const head = s.slice(0, colon).toUpperCase();
11
+ if (PREFIXES.has(head)) return head + ':' + s.slice(colon + 1).toUpperCase();
12
+ }
13
+
14
+ // Yahoo 风格后缀: 0700.HK / 600519.SS / 300750.SZ
15
+ const m = /^(\d{1,6})\.(HK|SS|SZ)$/i.exec(s);
16
+ if (m) {
17
+ const num = m[1];
18
+ const suf = m[2].toUpperCase();
19
+ if (suf === 'HK') return 'HK:' + num.padStart(5, '0');
20
+ return (suf === 'SS' ? 'SH:' : 'SZ:') + num;
21
+ }
22
+
23
+ if (/^\d{6}$/.test(s)) {
24
+ if (s[0] === '6') return 'SH:' + s; // 沪市主板/科创(688 也是 6 开头)
25
+ if (s[0] === '0' || s[0] === '3') return 'SZ:' + s; // 深市主板 / 创业板
26
+ return 'SH:' + s;
27
+ }
28
+ if (/^\d{1,5}$/.test(s)) return 'HK:' + s.padStart(5, '0'); // 港股代码习惯写短
29
+
30
+ return 'US:' + s.toUpperCase();
31
+ }