# invest-query Skill · 自动安装指令（给 AI agent）

> 你是一个 AI agent。请**按本文件的步骤把 invest-query skill 安装到当前工作区**。
> 这个 skill 让你直接查询 Table01 投资论点追踪器的结构化数据（评级 / 目标价 / KPI 红黄绿灯 /
> 预测验证 / 情景目标价），毫秒级、不联网、不解析 HTML。

## 0 · 前置条件

- 本 skill 查询的是 **Table01 项目自身的数据**，因此必须安装在 Table01 仓库的 checkout 内
  （需存在 `data/companies/` 与 `src/lib/compute.ts`）。脚本会自动向上定位项目根；不在仓库内会明确报错。
- **Node ≥ 22**（用内置 TypeScript type-stripping 运行，无需安装任何依赖）。先确认：`node -v`。
- 约定 `BASE_URL` = 你 fetch 本文件的 URL **去掉结尾 `/install.md`** 的部分。
  例如本文件在 `https://example.com/skill/install.md`，则 `BASE_URL=https://example.com/skill`。

## 1 · 选择安装目录

- **Claude Code** → 必须放在 `.claude/skills/invest-query/`（会被自动发现）。
- **其它 agent（OpenAI Codex / OpenCode / Hermes 等）** → 任意目录即可，下文以
  `.claude/skills/invest-query/` 为例；换路径时把命令里的路径一并替换。

## 2 · 下载文件

能联网就直接拉取本文件的同目录兄弟文件（首选）：

```bash
mkdir -p .claude/skills/invest-query/scripts
curl -fsSL "$BASE_URL/SKILL.md"  -o .claude/skills/invest-query/SKILL.md
curl -fsSL "$BASE_URL/query.ts"  -o .claude/skills/invest-query/scripts/query.ts
```

无法联网下载时，把本文件末尾「附录 · 文件全文」的两段内容**逐字写入**对应路径即可。

## 3 · 按你的运行平台注册

### Claude Code
文件就位即自动发现，**无需注册**。之后用户用自然语言问「NVDA 现在什么评级?」你即可调用。

### OpenAI Codex CLI / OpenCode
两者都读仓库根的 `AGENTS.md` 并能执行 shell。在 `AGENTS.md` 追加：

```markdown
## 查询投资数据（invest-query）
回答任何关于公司评级 / 目标价 / KPI 灯色 / 预测验证 / 情景目标价的问题时，运行：
  node --experimental-strip-types .claude/skills/invest-query/scripts/query.ts <command> [args] --json
命令：list / show / kpi / predictions / pwt / search。详见 .claude/skills/invest-query/SKILL.md。
```

### Hermes（function-calling）
把脚本登记成一个工具，函数定义：

```json
{
  "name": "invest_query",
  "description": "查询 Table01 结构化投资数据(评级/目标价/KPI/预测验证/情景目标价)",
  "parameters": {
    "type": "object",
    "properties": {
      "command": { "type": "string", "enum": ["list","show","kpi","predictions","pwt","search"] },
      "arg":     { "type": "string", "description": "公司 slug/ticker 或搜索关键词" },
      "flags":   { "type": "string", "description": "如 --rating BUY --kpi red" }
    },
    "required": ["command"]
  }
}
```

宿主收到调用时执行（始终带 `--json`），把 stdout 回灌给模型：

```bash
node --experimental-strip-types .claude/skills/invest-query/scripts/query.ts "$command" $arg $flags --json
```

## 4 · 验证安装

```bash
node --experimental-strip-types .claude/skills/invest-query/scripts/query.ts show nvda
```

看到「英伟达 (NVDA) …评级：…」之类的结构化输出即安装成功。脚本会自动向上定位项目根，任意目录下都能跑。

## 5 · 命令速查

| 命令 | 作用 | 示例 |
| --- | --- | --- |
| `list` | 筛选公司 | `list --rating BUY --kpi red` |
| `show <slug\|ticker>` | 单家全景 | `show nvda` |
| `kpi <slug\|ticker>` | KPI 红黄绿灯 | `kpi tsm` |
| `predictions <slug\|ticker>` | 验证追踪 | `predictions nvda --status falsified` |
| `pwt <slug\|ticker>` | 概率加权目标价 | `pwt nvda --horizon 12m` |
| `search <关键词>` | 模糊搜索 | `search 稳定币` |

任意命令加 `--json` 输出结构化结果。

---

## 附录 · 文件全文（无法下载时手动写入）

### `.claude/skills/invest-query/SKILL.md`

````markdown
---
name: invest-query
description: 查询本项目（table01 公司投资论点追踪器）的结构化投资数据，并在需要最新信息时联网抓取。当用户问某公司的评级/目标价/KPI 红黄绿灯/预测验证/情景目标价，或要按评级、KPI 灯色、板块、标签筛选公司，或要拉取某公司最新行情/财报/公告时使用。
---

# invest-query

把「查询网站信息」转化为「查询数据源」。本项目是纯静态投资论点追踪器，所有内容都来自
结构化 JSON（`data/companies/*.json`）与报告原文（`data/reports/*.md`）——**网页只是它们的
渲染**。所以查询直接读数据，不解析 HTML、不跑 build、不联网，毫秒级返回。

## 两种模式

### 模式一 · 本地查询（默认，优先用）

用脚本 `scripts/query.ts` 查本地结构化数据。它**直接 import 项目的 `src/lib/compute.ts`**，
派生量（财务指标、验证记分卡、概率加权目标价、上行空间）与页面、与更新 SOP 完全一致，
不重复实现。

运行（需 Node ≥ 22，使用内置 TS type-stripping，无需安装任何依赖）：

```bash
node --experimental-strip-types .claude/skills/invest-query/scripts/query.ts <command> [args] [--json]
```

命令：

| 命令 | 作用 | 示例 |
|---|---|---|
| `list` | 筛选公司列表 | `list --rating BUY --kpi red` |
| `show <slug\|ticker>` | 单家全景：评级/目标价/KPI/验证/论点 | `show nvda` · `show TSLA` |
| `kpi <slug\|ticker>` | KPI 红黄绿灯仪表盘（每指标最新观测） | `kpi tsm` |
| `predictions <slug\|ticker>` | 验证追踪：预测 → 实际 → 状态 | `predictions nvda --status falsified` |
| `pwt <slug\|ticker>` | 概率加权目标价（Σ 概率×区间中点） | `pwt nvda --horizon 12m` |
| `search <关键词>` | 跨名称/ticker/板块/标签/论点 模糊搜索 | `search 稳定币` |

`list` 的筛选标志：`--rating BUY|HOLD|SELL`、`--status active|watching|archived`、
`--kpi green|yellow|red`（含该色灯）、`--sector <文本>`、`--tag <文本>`。

公司可用 **slug**（如 `nvda`、`tesla`）或 **ticker**（如 `NVDA`、`TSLA`、`$TSM`，不区分大小写）定位。

预测状态枚举：`pending` · `verified_strong` · `verified_full` · `beat` · `needs_revision` · `falsified`。

**给 agent 链式处理时加 `--json`**：所有命令都能输出结构化 JSON，便于二次筛选/汇总，例如
跨公司挑出「BUY 且 12m 上行 > 30% 且无红灯」的标的。

### 模式二 · 联网抓取（按需）

仅当本地数据缺最新信息（实时行情、刚发布的财报/公告/新闻）时才联网：

1. 轻量取单页 → `WebFetch`；需要登录态或交互的真实页面 → `kimi-webbridge` skill（驱动用户真实浏览器）。
2. 抓回的原始信息**不要直接塞进页面**。按 `CLAUDE.md` 的「更新 SOP」清洗成结构化数据：
   写报告 markdown → 追加 `reports[]` → 映射 timeline/metrics/predictions/scenarios →
   更新顶层 thesis/price/price_targets → 用 `src/schema/company.ts` 的 zod 校验通过后保存。

## 使用准则

- **先本地，后联网**：能从 `data/companies` 答的，绝不联网。联网只为补本地没有的新鲜信息。
- **可溯源**：联网得到的新数据落库时，`source_report_id` 必须指向当次新建的报告。
- **不绕过契约**：写回数据一律走 zod 校验；查询派生量一律走 `compute.ts`，不要在别处另写一份算法。

## 数据契约速览

唯一数据契约在 `src/schema/company.ts`。Company 顶层字段：`slug` `name_zh` `name_en` `ticker`
`exchange` `sector` `tags` `status` `rating` `conviction` `position_target` `price`
`price_targets` `thesis_current` `updated_at`，以及五个数组 `reports` `timeline` `metrics`
`predictions` `scenarios`。详见 schema 与 `CLAUDE.md`。
````

### `.claude/skills/invest-query/scripts/query.ts`

````typescript
// invest-query 本地查询 CLI —— 直接读 data/companies/*.json，复用项目 compute.ts 的派生逻辑。
// 运行：node --experimental-strip-types query.ts <command> [args] [--json]
// 依赖：仅 Node >= 22（用内置 type-stripping 运行 TS，无需安装任何包）。
//
// 设计原则：查"数据源"而非"渲染后的网站"。compute 逻辑从项目源码 import，
// 永不重复实现，保证与页面/SOP 完全一致。

import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
// 注意：compute 在运行时从定位到的项目根动态 import（见下），不写死相对路径，
// 这样脚本无论安装在何处都能工作。下面是仅供编辑器的纯类型导入（运行时被剥离）。
import type { Company } from '../../../../src/schema/company.ts'
import type * as Compute from '../../../../src/lib/compute.ts'

// ---- 定位项目根（含 data/companies 的最近祖先目录）----
function findRoot(): string {
  let dir = dirname(fileURLToPath(import.meta.url))
  for (let i = 0; i < 8; i++) {
    if (existsSync(join(dir, 'data', 'companies'))) return dir
    const parent = dirname(dir)
    if (parent === dir) break
    dir = parent
  }
  // 退化：相对脚本位置（scripts → invest-query → skills → .claude → root）
  return resolve(dirname(fileURLToPath(import.meta.url)), '../../../..')
}

const ROOT = findRoot()
const COMPANIES_DIR = join(ROOT, 'data', 'companies')

// 从项目根动态加载派生逻辑（compute.ts 仅含纯类型导入，strip-types 下可直接运行）。
const COMPUTE_PATH = join(ROOT, 'src', 'lib', 'compute.ts')
if (!existsSync(COMPUTE_PATH)) {
  console.error(
    `错误：未找到 ${COMPUTE_PATH}。invest-query 需安装在 Table01 项目内（含 src/lib/compute.ts 与 data/companies/）。`,
  )
  process.exit(1)
}
const {
  kpiHealth,
  latestObservation,
  weightedTarget,
  scenarioHorizons,
  upsidePct,
  parsePriceMidpoint,
  verificationScorecard,
}: typeof Compute = await import(pathToFileURL(COMPUTE_PATH).href)

function loadCompanies(): Company[] {
  if (!existsSync(COMPANIES_DIR)) {
    fail(`找不到数据目录：${COMPANIES_DIR}`)
  }
  return readdirSync(COMPANIES_DIR)
    .filter((f) => f.endsWith('.json'))
    .map((f) => JSON.parse(readFileSync(join(COMPANIES_DIR, f), 'utf-8')) as Company)
    .sort((a, b) => a.slug.localeCompare(b.slug))
}

// 按 slug 或 ticker（不区分大小写）定位一家公司。
function findCompany(all: Company[], key: string): Company {
  const k = key.toLowerCase()
  const hit =
    all.find((c) => c.slug.toLowerCase() === k) ||
    all.find((c) => c.ticker.toLowerCase() === k) ||
    all.find((c) => c.ticker.toLowerCase() === k.replace(/^\$/, ''))
  if (!hit) fail(`未找到公司：${key}（可用：${all.map((c) => c.slug).join(', ')}）`)
  return hit
}

// ---- 参数解析 ----
type Flags = Record<string, string | true>
function parseArgs(argv: string[]): { positional: string[]; flags: Flags } {
  const positional: string[] = []
  const flags: Flags = {}
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a.startsWith('--')) {
      const key = a.slice(2)
      const next = argv[i + 1]
      if (next && !next.startsWith('--')) {
        flags[key] = next
        i++
      } else {
        flags[key] = true
      }
    } else {
      positional.push(a)
    }
  }
  return { positional, flags }
}

function fail(msg: string): never {
  console.error(`错误：${msg}`)
  process.exit(1)
}

let JSON_OUT = false
function out(human: () => void, data: unknown) {
  if (JSON_OUT) console.log(JSON.stringify(data, null, 2))
  else human()
}

// ---- 派生展示辅助 ----
function lightLine(c: Company): string {
  const h = kpiHealth(c)
  return `🟢${h.green} 🟡${h.yellow} 🔴${h.red}`
}

function companySummary(c: Company) {
  const h = kpiHealth(c)
  const card = verificationScorecard(c.predictions)
  const pwt12 = weightedTarget(c.scenarios, '12m')
  return {
    slug: c.slug,
    name: `${c.name_zh} (${c.ticker})`,
    sector: c.sector,
    status: c.status,
    rating: c.rating,
    conviction: c.conviction,
    position_target: c.position_target,
    price: c.price,
    price_targets: c.price_targets,
    upside_12m_pct: upsidePct(c.price.current, c.price_targets.h12m),
    weighted_target_12m: pwt12,
    kpi_health: h,
    verification: card,
    thesis_current: c.thesis_current,
    updated_at: c.updated_at,
    reports_count: c.reports.length,
  }
}

// ---- 命令 ----
function cmdList(flags: Flags) {
  let all = loadCompanies()
  if (flags.rating) all = all.filter((c) => c.rating === String(flags.rating).toUpperCase())
  if (flags.status) all = all.filter((c) => c.status === flags.status)
  if (flags.sector) all = all.filter((c) => c.sector.includes(String(flags.sector)))
  if (flags.tag) all = all.filter((c) => c.tags.some((t) => t.includes(String(flags.tag))))
  if (flags.kpi) {
    const want = String(flags.kpi) as 'green' | 'yellow' | 'red'
    all = all.filter((c) => kpiHealth(c)[want] > 0)
  }
  out(
    () => {
      if (all.length === 0) return console.log('（无匹配公司）')
      for (const c of all) {
        console.log(
          `${c.ticker.padEnd(8)} ${c.rating.padEnd(4)} c${c.conviction}  ${lightLine(c)}  ${c.name_zh} — ${c.thesis_current.slice(0, 40)}`,
        )
      }
      console.log(`\n共 ${all.length} 家。`)
    },
    all.map(companySummary),
  )
}

function cmdShow(positional: string[]) {
  const all = loadCompanies()
  const c = findCompany(all, positional[0] ?? fail('用法：show <slug|ticker>'))
  const s = companySummary(c)
  out(() => {
    console.log(`# ${s.name}  [${c.exchange}]`)
    console.log(`板块：${s.sector}   标签：${c.tags.join('、')}`)
    console.log(`评级：${s.rating}  信念：${s.conviction}/5  目标仓位：${s.position_target}  状态：${s.status}`)
    console.log(
      `现价：${c.price.current ?? '—'} ${c.price.currency}（${c.price.as_of ?? '—'}）  12m目标：${c.price_targets.h12m ?? '—'}  3y目标：${c.price_targets.h3y ?? '—'}`,
    )
    if (s.upside_12m_pct != null) console.log(`12m 上行空间：${s.upside_12m_pct.toFixed(1)}%`)
    if (s.weighted_target_12m != null)
      console.log(`概率加权目标价(12m)：${s.weighted_target_12m.toFixed(1)}`)
    console.log(`财务指标：${lightLine(c)}`)
    console.log(`验证记分卡：${JSON.stringify(s.verification)}`)
    console.log(`\n核心论点：${s.thesis_current}`)
    console.log(`\n最近更新：${s.updated_at}   报告数：${s.reports_count}`)
  }, s)
}

function cmdKpi(positional: string[]) {
  const all = loadCompanies()
  const c = findCompany(all, positional[0] ?? fail('用法：kpi <slug|ticker>'))
  const rows = c.metrics.map((m) => {
    const obs = latestObservation(m)
    return {
      name: m.name,
      category: m.category,
      latest: obs ? { period: obs.period, value: obs.value, status: obs.status, as_of: obs.as_of } : null,
      thresholds: m.thresholds,
    }
  })
  const dot = (s?: string) => (s === 'green' ? '🟢' : s === 'yellow' ? '🟡' : s === 'red' ? '🔴' : '⚪️')
  out(() => {
    console.log(`# ${c.name_zh} (${c.ticker}) KPI 仪表盘  ${lightLine(c)}\n`)
    for (const r of rows) {
      console.log(
        `${dot(r.latest?.status)} ${r.name.padEnd(20)} ${r.latest ? `${r.latest.value} @ ${r.latest.period}` : '（无观测）'}`,
      )
    }
  }, rows)
}

function cmdPredictions(positional: string[], flags: Flags) {
  const all = loadCompanies()
  const c = findCompany(all, positional[0] ?? fail('用法：predictions <slug|ticker> [--status ...]'))
  let preds = c.predictions
  if (flags.status) preds = preds.filter((p) => p.status === flags.status)
  out(() => {
    console.log(`# ${c.name_zh} (${c.ticker}) 验证追踪  ${JSON.stringify(verificationScorecard(c.predictions))}\n`)
    if (preds.length === 0) return console.log('（无匹配预测）')
    for (const p of preds) {
      console.log(`[${p.status}] ${p.statement}`)
      console.log(`   预期：${p.expected ?? '—'}  实际：${p.actual ?? '—'}  验证于：${p.verified_date ?? '未'}`)
    }
  }, preds)
}

function cmdPwt(positional: string[], flags: Flags) {
  const all = loadCompanies()
  const c = findCompany(all, positional[0] ?? fail('用法：pwt <slug|ticker> [--horizon 12m]'))
  const horizons = flags.horizon ? [String(flags.horizon)] : scenarioHorizons(c.scenarios)
  const results = horizons.map((h) => ({
    horizon: h,
    weighted_target: weightedTarget(c.scenarios, h),
    scenarios: c.scenarios
      .filter((s) => s.horizon === h)
      .map((s) => ({ name: s.name, probability: s.probability, price_target: s.price_target, mid: parsePriceMidpoint(s.price_target) })),
  }))
  out(() => {
    console.log(`# ${c.name_zh} (${c.ticker}) 概率加权目标价\n`)
    for (const r of results) {
      console.log(`${r.horizon}: ${r.weighted_target != null ? r.weighted_target.toFixed(1) : '不可计算（含非数值目标）'}`)
      for (const s of r.scenarios) console.log(`   ${s.name.padEnd(5)} p=${s.probability}  ${s.price_target} (中点 ${s.mid ?? '—'})`)
    }
  }, results)
}

function cmdSearch(positional: string[]) {
  const q = (positional[0] ?? fail('用法：search <关键词>')).toLowerCase()
  const all = loadCompanies().filter(
    (c) =>
      c.name_zh.toLowerCase().includes(q) ||
      c.name_en.toLowerCase().includes(q) ||
      c.ticker.toLowerCase().includes(q) ||
      c.sector.toLowerCase().includes(q) ||
      c.tags.some((t) => t.toLowerCase().includes(q)) ||
      c.thesis_current.toLowerCase().includes(q),
  )
  out(() => {
    if (all.length === 0) return console.log('（无匹配）')
    for (const c of all) console.log(`${c.ticker.padEnd(8)} ${c.rating}  ${c.name_zh} — ${c.thesis_current.slice(0, 50)}`)
  }, all.map(companySummary))
}

function help() {
  console.log(`invest-query —— 本地投资论点查询（读 data/companies/*.json，复用 compute.ts）

用法：node --experimental-strip-types query.ts <command> [args] [--json]

命令：
  list [--rating BUY|HOLD|SELL] [--status active|watching|archived]
       [--kpi green|yellow|red] [--sector <文本>] [--tag <文本>]   筛选公司列表
  show <slug|ticker>                          单家公司全景（评级/目标价/KPI/验证/论点）
  kpi  <slug|ticker>                          KPI 红黄绿灯仪表盘（每指标最新观测）
  predictions <slug|ticker> [--status <s>]    验证追踪（预测 → 实际 → 状态）
  pwt  <slug|ticker> [--horizon 12m]          概率加权目标价
  search <关键词>                              跨名称/ticker/板块/标签/论点 模糊搜索

全局：--json  输出结构化 JSON（供 agent 链式处理）

预测状态：pending | verified_strong | verified_full | beat | needs_revision | falsified`)
}

// ---- 入口 ----
function main() {
  const raw = process.argv.slice(2)
  JSON_OUT = raw.includes('--json')
  const { positional, flags } = parseArgs(raw.filter((a) => a !== '--json'))
  const cmd = positional.shift()
  switch (cmd) {
    case 'list':
      return cmdList(flags)
    case 'show':
      return cmdShow(positional)
    case 'kpi':
      return cmdKpi(positional)
    case 'predictions':
      return cmdPredictions(positional, flags)
    case 'pwt':
      return cmdPwt(positional, flags)
    case 'search':
      return cmdSearch(positional)
    case undefined:
    case 'help':
    case '--help':
      return help()
    default:
      fail(`未知命令：${cmd}（运行 help 查看用法）`)
  }
}

main()
````
