返回 Skills 目录
wind-information-co-ltd/wind-skills包含需要注意的行为

SKILL DETAIL

wind-mcp-skill

wind-information-co-ltd/wind-skills/wind-mcp-skill

wind-mcp-skill 是万得(Wind)官方提供的金融数据技能,通过本地 CLI 调用 Wind 的 7 个 MCP 服务,为用户提供可靠、可验证的全球金融市场数据。该技能覆盖范围广泛,包括 A 股、港股、美股的选股、行情、K 线、财务、估值、股东与事件,以及基金、ETF、指数、板块、债券、公告、财经新闻、宏观经济、汇率、行业、企业、风控、量化指标和衍生品等数据。 技能内置了严格的路由和调用流程,确保数据准确性和可追溯性。用户可以通过自然语言提出查询需求,技能会自动路由到对应的数据服务,并基于返回结果回答,不添加额外评论。该技能支持批量查询、并发控制,并提供了详细的错误处理和自检机制,确保在复杂场景下也能稳定运行。

安装量 · 1,087查看来源

Installation

npx skills add https://github.com/wind-information-co-ltd/wind-skills --skill wind-mcp-skill

技能文件

SKILL.md

最近同步 · 2026年8月29日

references/analytics.md
# `analytics_data` 工具契约

仅当专项服务无法覆盖结构化取数时使用;不得替代行情、K 线、Quote 或价格指标。自然语言统一使用 `question`。

- 首次调用保持用户原意,不增加筛选条件。
- 首次失败、空数据或明显不匹配后,才可在同一取数意图内改写或拆分一次。

## 工具契约

### `get_financial_data`

根据自然语言描述执行结构化数据的计算与提取,支持自定义指标组合、跨实体聚合与灵活的数据加工。仅当所需结果不能由任何预定义数据工具直接返回时使用本工具:单一实体的属性数据、指数与板块的预定义加权指标、宏观指标均调用相应数据服务;按条件筛选并返回实体列表使用各服务的筛选工具(本工具返回计算结果而非实体列表)。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询中国A股市场过去一年的平均成交量" | 自然语言计算与取数要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含实体范围、指标或计算逻辑、日期(或时间范围、报告期)等要素。|
references/bond.md
# `bond_data` 工具契约

只用于债券;本服务没有行情快照、K 线或 Quote 工具。参数名称、类型、必填项、示例与默认值和枚举以本文件各工具的契约为准。

## 工具契约

### `get_bond_basicinfo`

返回指定债券的静态档案数据,包括发行要素(如发行规模、票面利率、期限、起息与到期日期)、信用评级、债券类型等。不含行情与估值数据、发债主体信息。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询国债2601的基本信息,包括发行日期、票面利率和期限" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含债券实体、指标名称、日期等查询要素。|

### `get_bond_issuer_info`

返回指定债券发债主体的档案信息,包括主体基本信息、主体信用评级等。债券属性与主体财务数据请使用相应工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询国债2601发债主体的公司信息,包括注册地址和行业分类" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含债券实体、指标名称、日期等查询要素。|

### `get_bond_market_data`

返回指定债券在指定时间范围内的区间行情与估值数据,频率支持日、周、月、年及自定义区间,包括价格、收益率、估值指标等。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询国债2601的行情数据,包括久期和凸性" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含债券实体、指标名称、日期(或时间范围、频率)等查询要素。|

### `get_bond_financial_data`

返回指定债券发债主体的财务数据,时间维度为报告期。主体档案信息与债券本身属性请使用相应工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询国债2601发债主体2024年营业收入和净利润" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含债券实体、指标名称、日期(或报告期)等查询要素。|
references/economic.md
# `economic_data` 工具契约

只用于宏观和行业 EDB 指标。自然语言统一使用 `question`;日期统一使用 `beginDate` / `endDate`。

- 按职责分两个工具:找指标 / 确认代码用 `search_economic_indicator`;取具体数值时间序列用 `query_economic_indicator_data`。
- `query_economic_indicator_data` 必须提供完整日期范围(`beginDate` + `endDate`)或 `observation`,两者互斥;只给 `question` 会被后端拒绝。
- 日期字段使用 `beginDate` / `endDate`,格式 `yyyy-MM-dd`。
- `observation` 为数字字符串(近 N 期,如 `10`)。
- 后端将合法日期误报为 observation 格式错误时,视为后端问题:停止自动修正并透传错误。
- 不得把日期范围擅自改成 `observation`。

## 工具契约

### `search_economic_indicator`(找指标 / 确认代码,不取数)

根据自然语言需求,从 Wind EDB 经济数据库中检索并匹配相关经济指标,返回指标的元信息(指标名称、指标代码、频率、单位、来源等),**不返回具体数值数据**。适用于查找可用指标、筛选指标,以及提数前确认指标代码的场景。

输入说明:
`question`:用户的自然语言搜索问句,例如“中国近三年GDP相关指标”“上海CPI有哪些”“有哪些出口相关指标”。

返回结果:`metrics` 数组,每条为扁平的指标元信息对象(`code`、`name`、`unit`、`source`、`magnitude`、`currency`、`updateDate`、`freq`;`%` 类指标可能省略 `magnitude`/`currency`),不含时间序列。

| 参数 | 必填 | 类型 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- |
| `question` | 是 | string | 中国近三年GDP相关指标 | 自然语言搜索问句。仅描述要找的指标,不填时间与换算参数。 |

### `query_economic_indicator_data`(取时间序列数值)

根据自然语言问句**或指标代码**,从 Wind EDB 获取宏观经济指标的时间序列数据。`question` 既可传自然语言(如“提取中国GDP数据”),也可直接传指标代码(如 `M5567876`,多个代码用英文逗号分隔)。时间范围只能通过 `beginDate`/`endDate` 或 `observation` 传入,**不要塞进 `question`**。

调用约束:必须显式提供 `beginDate`+`endDate` 或 `observation`;只给 `question` 后端会返回“observation或者[beginDate、endDate]必须填一个”。

返回结果:`metrics` 数组,每条为 `{ meta, date[], value[] }`——`meta` 为指标元信息(同上 8 字段),`date[]` 与 `value[]` 为等长并行的日期与数值数组。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 中国GDP现价当季值 / `M5567876` | 自然语言问句或指标代码(多个代码用英文逗号分隔)。时间范围通过 `beginDate`/`endDate` 或 `observation` 显式传入,不要写进 `question`。 |
| `beginDate` | 否 | string | — | 2025-01-01 | 数据提取开始日期,格式 `yyyy-MM-dd`。须与 `endDate` 成对出现;与 `observation` 互斥。 |
| `endDate` | 否 | string | — | 2025-12-31 | 数据提取结束日期,格式 `yyyy-MM-dd`。须与 `beginDate` 成对出现;与 `observation` 互斥。 |
| `observation` | 否 | string | — | 10 | 观测期数,近 N 期填数字字符串(如近10期填 `10`)。与 `beginDate`/`endDate` 互斥。 |

> 说明:本工具在本 skill 中只接受 `question` 与时间范围参数(`beginDate`/`endDate`/`observation`);跨口径换算 / 对齐交由 `analytics_data` 处理。
references/financial-docs.md
# `financial_docs` 工具契约

只用于公告和财经新闻。自然语言检索统一使用 `query`(本域是例外,不同于其它领域的 `question`);其余参数以本文件各工具的契约为准。

## 工具契约

### `get_company_announcements`

检索并返回上市公司公告文本,支持按公司、公告类型、时间范围等条件检索。公告对应的结构化字段(如分红金额、事件日期)需查询相应实体的数据;第三方媒体报道不在本工具范围。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `query` | 是 | string | — | 示例:"查询贵州茅台2024年的分红公告" | 自然语言检索要求,应包含公司实体(可选)、公告类型、日期(或时间范围)等检索要素。|
| `top_k` | 否 | integer | — | 默认:5 | 返回的相关文档或片段的最大数量 |

### `get_financial_news`

检索并返回财经新闻文本,支持按主题、实体、时间范围等条件检索。不包含发行人官方公告与券商研报;新闻中提及的结构化数据(如价格、财务数值)以相应实体的数据返回为准。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `query` | 是 | string | — | 示例:"近一周关于宁德时代固态电池的新闻" | 自然语言检索要求,应包含主题或相关实体、日期(或时间范围)等检索要素。|
| `top_k` | 否 | integer | — | 默认:5 | 返回的相关文档或片段的最大数量 |
references/fund-indicators.md
## `indexes` 行情指标

仅供 `get_fund_price_indicators` 使用。下列字段全部经过真实调用验证,可直接使用;只选择用户明确请求的字段,逐字复制,多个字段用英文逗号连接。表内没有的字段不得猜测。

### 基础行情与元信息

`最新交易日`、`交易时间`、`中文简称`、`最新成交价`、`前收盘价`、`今日开盘价`、`今日最高价`、`今日最低价`、`最新均价`、`涨跌`、`涨跌幅`、`成交量`、`成交额`、`交易状态`、`上市日期`、`近1分钟成交额`、`近3分钟成交额`、`近5分钟成交额`、`近7日平均成交额`、`换手率`、`量比`、`振幅`、`基于Wind算法的量比`

### 盘口与逐笔

`现量`、`现额`、`买一价`、`买二价`、`买三价`、`买四价`、`买五价`、`卖一价`、`卖二价`、`卖三价`、`卖四价`、`卖五价`、`买一量`、`买二量`、`买三量`、`买四量`、`买五量`、`卖一量`、`卖二量`、`卖三量`、`卖四量`、`卖五量`、`外盘`、`内盘`、`成交笔数`、`委比`

### 资金流向

`当日主力净流入额`、`当日主力净流入占比`、`近5日主力净流入额`、`近5日主力净流入占比`、`近5日主力净流入天数`、`近10日主力净流入额`、`近10日主力净流入占比`、`近10日主力净流入天数`、`近20日主力净流入额`、`近20日主力净流入占比`、`近20日主力净流入天数`、`近60日主力净流入额`、`近60日主力净流入占比`、`近60日主力净流入天数`、`主力挂单买入`、`主力挂单卖出`、`主力撤单买入`、`主力撤单卖出`

### 盘中异动

`连续上涨天数`、`连红天数`、`火箭发射`、`高台跳水`、`涨停封板`、`跌停封板`、`涨停开板`、`跌停开板`、`涨幅达到3%`、`跌幅达到3%`、`创20日新高`、`创20日新低`

### 盘前盘后

`盘后最新价`、`盘后涨跌幅`

### 技术指标

`指数平滑异同移动平均`、`DIF快线`、`随机指标K值`、`随机指标D值`、`随机指标J值`、`6周期相对强弱指标`、`12周期相对强弱指标`、`抛物线转向指标`、`布林中轨`、`布林上轨`、`布林下轨`、`5周期移动平均`、`10周期移动平均`、`20周期移动平均`、`60周期移动平均`、`120周期移动平均`、`250日均线`、`5日乖离率`、`36日乖离`、`14周期顺势指标`、`26周期能量指标`、`12周期心理线指标`、`近1分钟涨跌幅`、`近3分钟涨跌幅`、`MACD多头金叉信号`、`MACD空头死叉信号`

### 多周期涨跌幅

`5分钟涨跌幅`、`5日涨跌幅`、`10日涨跌幅`、`20日涨跌幅`、`60日涨跌幅`、`120日涨跌幅`、`250日涨跌幅`、`年初至今涨跌幅`、`上市以来涨跌幅`、`近3年涨跌幅`、`近5年涨跌幅`、`近10年涨跌幅`

### 净值与规模

`流通份额`、`最新净值`、`上期净值`、`累计净值`、`最新净值增长率`、`年初以来净值增长率`、`成立以来净值增长率`、`近一周净值增长率`、`近一月净值增长率`、`近一季净值增长率`、`近半年净值增长率`、`近一年净值增长率`、`近两年净值增长率`、`近三年净值增长率`、`近五年净值增长率`、`贴水率`、`基金规模`、`七日年化收益率`、`万份基金收益`、`IOPV`

### 估值与市值

`流通市值`、`涨停价`、`跌停价`

### 使用说明

- `贴水率` 就是场内溢折率:正值为溢价、负值为折价(实测纳指 QDII 为 16.394,沪深300ETF 为 1.141)。
- `七日年化收益率` 和 `万份基金收益` 只对货币基金返回有效值,股票型 / 指数型 ETF 恒为 0.00。
- `IOPV` 仅部分场内基金返回;货币 ETF 不返回该字段。
- 行情价格、`IOPV`、`贴水率` 是判断 ETF 日内交易的一组核心指标,通常配合使用。

references/fund.md
# `fund_data` 工具契约

只用于基金、ETF、LOF。参数名称、类型、必填项、示例与默认值和枚举以本文件各工具的契约为准。

- `search_funds` 只用于未指定具体产品的基金筛选。
- `indexes` 逐字取自 `references/fund-indicators.md`;用户未指定字段时省略 `indexes` 走默认值,指定字段时才读取该指标集。
- 场外基金代码如 `005827.OF`;ETF/LOF 代码如 `588200.SH`、`159915.SZ`。

## 目录

- [工具契约](#工具契约)
- 行情指标集:`references/fund-indicators.md`(仅 `get_fund_price_indicators` 需要)

## 工具契约

### `get_fund_price_indicators`

返回指定场内交易基金(ETF/LOF)当前时刻的截面状态,为时点数据,包括最新价、今日开高低、涨跌幅、成交量、成交额、换手率等行情指标的最新值。仅返回时点截面,不含过程序列;场外基金的净值查询不在本工具范围。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 一个或多个基金名称或者基金代码,如588200.SH;多个用英文逗号分隔,单次最多 50 个,超过请分批查询。 |
| `indexes` | 否 | string | — | 默认:"最新交易日,交易时间,最新成交价,前收盘价,今日开盘价,今日最高价,今日最低价,成交量" | 指标字段,多个字段用英文逗号分隔;可选值见 `references/fund-indicators.md`,构造前先读取该文件并逐字复制。 |

### `get_fund_kline`

返回指定场内交易基金(ETF/LOF)在给定时间范围内的聚合价格序列(K 线),聚合周期由 period 指定(分钟级至年,默认日 K)。每条记录代表一个周期,包含开盘价、收盘价、最高价、最低价、成交量、成交额、换手率与均价。当日盘中分钟走势建议用分钟级行情工具(缺省即最新交易日);场外基金无场内行情,其价格口径为净值(属业绩与评价数据)。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | Wind代码,格式如 588200.SH 或 159915.SZ,用于标识具体的场内基金(ETF/LOF) |
| `begin_date` | 是 | string | — | — | 开始日期:必须显式填写绝对日期,格式 yyyy-MM-dd,如 2026-03-25。 |
| `end_date` | 是 | string | — | — | 结束日期:必须显式填写绝对日期,格式 yyyy-MM-dd,如 2026-03-25。 |
| `period` | 否 | string | 1min / 5min / 10min / 15min / 30min / 60min / 120min / 240min / 1d / 1w / 1mo / 1y / 1q / 6mo | 默认:"1d" | K 线周期。 |
| `count` | 否 | integer | — | 默认 0 | 在开始/结束日期区间内取数的条数(整数):正数从开始日期往后取 N 条,负数从结束日期往前取 N 条,0 取区间全部;不会超出日期区间。 |
| `aftype` | 否 | string | 0 / 1 / 2 | 默认 0 | 复权类型:0=前复权,1=后复权,2=不复权。前复权更常用 |
| `issusp` | 否 | string | — | 默认 1 | 是否包含停牌数据:0=不包含,1=包含 |
| `afdate` | 否 | string | — | — | 复权基准日期,格式 yyyy-MM-dd,如 2026-03-25。通常不需要指定。 |

### `get_fund_financials`

返回指定基金的财务报表与分红数据,时间维度为报告期,包括:利润指标(如基金利润、份额利润);收入与费用(如利息收入、投资收益、公允价值变动、管理费、托管费);报告期口径的资产净值;分红记录(如分红次数、单位分红、分红总额、分红条款)。不含净值序列与业绩评价数据。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | — | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含基金实体、指标名称、日期(或报告期)等查询要素。|

### `get_fund_holdings`

返回指定基金的投资组合数据,随定期报告披露更新,包含两族。披露持仓族:资产类别构成(如股票、债券、存款占净值比例及期间变动);重仓资产(如重仓股票、重仓债券、FOF 的重仓基金,及占流通股比例、持仓变动等指标);行业配置(如申万、Wind、中信口径)。组合派生特征族:由持仓计算的组合层面指标(如持股集中度、组合估值、基金换手率等)。仅返回基金组合层面的数据,个券的详细属性需以对应代码查询相应实体的数据。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | — | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含基金实体、指标名称、日期(或报告期)等查询要素。|

### `get_fund_company_info`

返回指定基金的管理人(基金管理公司)档案,为以基金为入口的引用实体查询,包括:公司基本信息(如名称、成立日期、注册资本、管理层);基金经理团队(如人数、人均管理产品数、任职年限统计);在管规模(如合计规模及排名、非货币规模、旗下基金数量);公司层面资产配置。注意本工具返回的是管理人公司而非基金产品本身的数据,基金产品属性请使用其他基金属性查询工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | — | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含基金实体、指标名称、日期等查询要素。|

### `get_fund_quote`

返回指定场内交易基金(ETF/LOF)的分钟级价格序列,每条记录代表一分钟,包含价格、均价、成交量与换手率。时间范围由 begin/end 指定(含首尾),未指定默认最新交易日;单日约 240 条,跨日体积按天数放大,长区间建议改用 K 线工具的聚合周期。仅交易日有数据,非交易日返回空结果。场外基金无场内行情,其价格口径为净值(属业绩与评价数据)。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 基金名称或者基金代码,如588200.SH。|
| `begin` | 否 | string | — | — | 开始日期,格式 yyyy-MM-dd,如 2026-03-25;未指定默认最新交易日。不可只传 end 不传 begin。|
| `end` | 否 | string | — | — | 结束日期,格式 yyyy-MM-dd,如 2026-03-25;未指定默认最新交易日(即只传 begin 时返回 begin 至最新交易日的区间)。|
| `count` | 否 | integer | — | 默认 0 | 在 begin/end 区间内取数的条数(整数):正数从 begin 往后取 N 条,负数从 end 往前取 N 条,0 取区间全部;不会超出区间;未指定 begin/end 时按默认最新交易日计。|

### `get_fund_info`

返回指定基金的产品档案,为静态或准静态数据,包括:代码、简称、全称;投资类型与风格;业绩比较基准与风险等级;费率结构(如管理费、托管费、申购赎回费);基金经理(现任与历任,如任职期限、管理规模);生命周期记录(如成立、转型、清盘、更名);运作状态(如申购赎回状态);管理人与托管人;发行信息(如成立日期、发行规模);指数跟踪信息(如跟踪指数、上市日期、封闭运作期)。不含规模与变动、业绩数据与持仓明细。


| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | — | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含基金实体、指标名称、日期等查询要素。|

### `get_fund_holders`

返回指定基金的份额、规模与持有人结构,包括:最新规模(资产净值、份额总数)及期间变动;持有人结构(如个人与机构持有比例、持有人户数);申购赎回情况(如报告期及单季度份额变动)。不含静态产品档案。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | — | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含基金实体、指标名称、日期(或报告期)等查询要素。|

### `get_fund_performance`

返回指定基金的净值、业绩度量与评价数据,覆盖三族。净值族:单位净值、累计净值与复权净值的序列及净值增长,净值是基金资产的单位估值,为场外基金的唯一价格口径,场内基金亦适用。业绩度量族:收益率(如区间收益率、年化收益率、绝对收益口径)与同类排名(如按多周期回报、按规模);货币基金以万份收益、7 日年化收益率为专项业绩口径。评价族:风险调整指标(如 Alpha、Beta、夏普比率、最大回撤、波动率、跟踪误差);能力归因指标(如选股能力、选时能力);风格分析(如风格箱、风格暴露);基金评级(为第三方评价结果,非净值派生指标);ETF/LOF 专项指标(如折溢价率、IOPV、净流入额)。不含基金财务报表数据。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | — | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含基金实体、指标名称、日期(或时间范围)等查询要素。 |

### `search_funds`

根据自然语言筛选条件从全市场基金反查产品,返回符合条件的基金代码列表。支持按指标数值(业绩、规模、费率、持仓特征等)与产品分类(投资类型、风格、指数跟踪、管理公司等)组合条件,返回的代码可传入各属性查询工具获取具体数据。已指定具体基金时不要调用本工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"筛选股票型基金中近一年收益率超20%的产品" | 自然语言筛选条件,为指标与指标参数(运算条件、阈值)的组合,无需指定实体。 |

行情指标集已独立为 `references/fund-indicators.md`,仅构造 `get_fund_price_indicators` 的 `indexes` 参数时读取。
references/index-indicators.md
## `indexes` 行情指标

仅供 `get_index_price_indicators` 使用。下列字段全部经过真实调用验证,可直接使用;只选择用户明确请求的字段,逐字复制,多个字段用英文逗号连接。表内没有的字段不得猜测。

### 基础行情与元信息

`最新交易日`、`交易时间`、`中文简称`、`最新成交价`、`前收盘价`、`今日开盘价`、`今日最高价`、`今日最低价`、`最新均价`、`涨跌`、`涨跌幅`、`成交量`、`成交额`、`交易状态`、`近1分钟成交额`、`近3分钟成交额`、`近5分钟成交额`、`换手率`、`量比`、`振幅`、`基于Wind算法的量比`、`上市日期`

### 盘口与逐笔

`现量`、`现额`

### 资金流向

`当日主力净流入额`、`当日主力净流入占比`、`近5日主力净流入额`、`近5日主力净流入占比`、`近5日主力净流入天数`、`近10日主力净流入额`、`近10日主力净流入占比`、`近10日主力净流入天数`、`近20日主力净流入额`、`近20日主力净流入占比`、`近20日主力净流入天数`、`近60日主力净流入额`、`近60日主力净流入占比`、`近60日主力净流入天数`

### 盘中异动

`连续上涨天数`、`连红天数`

### 技术指标

`指数平滑异同移动平均`、`DIF快线`、`随机指标K值`、`随机指标D值`、`随机指标J值`、`6周期相对强弱指标`、`12周期相对强弱指标`、`抛物线转向指标`、`布林中轨`、`布林上轨`、`布林下轨`、`5周期移动平均`、`10周期移动平均`、`20周期移动平均`、`60周期移动平均`、`120周期移动平均`、`250日均线`、`5日乖离率`、`36日乖离`、`14周期顺势指标`、`26周期能量指标`、`12周期心理线指标`、`近1分钟涨跌幅`、`近3分钟涨跌幅`、`MACD多头金叉信号`、`MACD空头死叉信号`

### 多周期涨跌幅

`5分钟涨跌幅`、`5日涨跌幅`、`10日涨跌幅`、`20日涨跌幅`、`60日涨跌幅`、`120日涨跌幅`、`250日涨跌幅`、`年初至今涨跌幅`、`上市以来涨跌幅`、`近3年涨跌幅`、`近5年涨跌幅`、`近10年涨跌幅`、`近20年涨跌幅`

### 估值与市值

`市净率`、`市净率(LF)`、`市盈率(TTM)`、`市盈率(预测)`、`总市值1`、`流通市值`、`52周最高`、`52周最低`

### 成分统计

`上涨家数`、`下跌家数`、`平盘家数`

### 使用说明

- 指数无盘口、无涨跌停、无盘前盘后交易,相关字段一律不可用。
- `总市值1` 和 `流通市值` 为成分股合计口径。

references/index.md
# `index_data` 工具契约

只用于指数和板块。参数名称、类型、必填项、示例与默认值和枚举以本文件各工具的契约为准。

- `indexes` 逐字取自 `references/index-indicators.md`;用户未指定字段时省略 `indexes` 走默认值,指定字段时才读取该指标集。
- 已确认的标准代码可直接传,例如 `000300.SH`、`HSI.HI`;不得猜测未知后缀。

## 目录

- [工具契约](#工具契约)
- 行情指标集:`references/index-indicators.md`(仅 `get_index_price_indicators` 需要)

## 工具契约

### `get_index_technicals`

返回指定指数基于历史行情序列计算的派生指标,包括相对窗口统计(如区间涨跌幅、均线、波动)与量价资金流向指标(如 MFI)。不含原始点位序列与实时快照。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询沪深300指数的技术指标,包括MACD、RSI和20日涨跌幅" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含指数或板块实体、指标名称、日期等查询要素。|

### `get_index_quote`

返回指定指数的分钟级点位序列,每条记录代表一分钟。时间范围由 begin/end 指定(含首尾),未指定默认最新交易日;单日约 240 条,跨日体积按天数放大,长区间建议改用 K 线工具的聚合周期。仅交易日有数据,非交易日返回空结果。当前时刻截面状态请用行情快照工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 指数名称或者指数代码,如沪深300或000300.SH |
| `begin` | 否 | string | — | — | 开始日期,格式 yyyy-MM-dd,如 2026-03-25;未指定默认最新交易日。不可只传 end 不传 begin。 |
| `end` | 否 | string | — | — | 结束日期,格式 yyyy-MM-dd,如 2026-03-25;未指定默认最新交易日(即只传 begin 时返回 begin 至最新交易日的区间)。 |
| `count` | 否 | integer | — | 默认 0 | 在 begin/end 区间内取数的条数(整数):正数从 begin 往后取 N 条,负数从 end 往前取 N 条,0 取区间全部;不会超出区间;未指定 begin/end 时按默认最新交易日计。 |

### `get_index_kline`

返回指定指数在给定时间范围内的聚合点位序列(K 线),聚合周期由 period 指定(分钟级至年,默认日 K)。每条记录代表一个周期,包含开盘价、收盘价、最高价、最低价等。当日盘中分钟走势建议用分钟级行情工具(缺省即最新交易日);相对窗口统计与技术指标不在本工具范围。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 指数名称或者指数代码,如沪深300或000300.SH |
| `begin_date` | 是 | string | — | — | 开始日期:必须显式填写绝对日期,格式 yyyy-MM-dd,如 2026-03-25。 |
| `end_date` | 是 | string | — | — | 结束日期:必须显式填写绝对日期,格式 yyyy-MM-dd,如 2026-03-26。 |
| `period` | 否 | string | 1min / 5min / 10min / 15min / 30min / 60min / 120min / 240min / 1d / 1w / 1mo / 1y / 1q / 6mo | 默认:"1d" | K 线周期。 |
| `count` | 否 | integer | — | 默认 0 | 在开始/结束日期区间内取数的条数(整数):正数从开始日期往后取 N 条,负数从结束日期往前取 N 条,0 取区间全部;不会超出日期区间。 |
| `aftype` | 否 | string | 0 / 1 / 2 | 默认 0 | 复权类型:0=前复权,1=后复权,2=不复权。前复权更常用 |
| `issusp` | 否 | string | — | 默认 1 | 是否包含停牌数据:0=不包含,1=包含 |
| `afdate` | 否 | string | — | — | 复权基准日期,格式 yyyy-MM-dd,如 2026-03-25。通常不需要指定。 |

### `get_index_fundamentals`

返回指定指数的基本面与估值指标,按成份股加权聚合口径(如加权 PE、PB、股息率)。成份股个股的财务明细需以个股代码查询相应实体的数据;不含非预定义口径的临时聚合计算。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询沪深300指数2024年的基本面数据,包括PE、PB和成份股加权营收" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含指数或板块实体、指标名称、日期等查询要素。|

### `get_index_price_indicators`

返回指定指数当前时刻的截面状态,为时点数据,包括最新点位、今日开高低、涨跌幅、成交量、成交额等行情指标的最新值。仅返回时点截面,不含过程序列。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 一个或多个指数名称或者指数代码,如沪深300或000300.SH;多个用英文逗号分隔,单次最多 50 个,超过请分批查询。 |
| `indexes` | 否 | string | — | 默认:"最新交易日,交易时间,最新成交价,前收盘价,今日开盘价,今日最高价,今日最低价,成交量" | 指标字段,多个字段用英文逗号分隔;可选值见 `references/index-indicators.md`,构造前先读取该文件并逐字复制。 |

### `get_index_basicinfo`

返回指定指数的静态概况与关联信息,包括:基本信息(如全称、发布机构、基日与基点);集合属性(如指数风格、成份数量);关联信息(如全收益指数、净收益指数代码,被跟踪的基金列表与数量)。仅返回档案、数量与关联信息,不含成份明细与权重、行情数据。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询沪深300指数的基本信息,包括发布机构、基日和成份股数量" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含指数或板块实体、指标名称、日期等查询要素。 |

行情指标集已独立为 `references/index-indicators.md`,仅构造 `get_index_price_indicators` 的 `indexes` 参数时读取。
references/stock-indicators.md
## `indexes` 行情指标

仅供 `get_stock_price_indicators` 使用。下列字段全部经过真实调用验证,可直接使用;只选择用户明确请求的字段,逐字复制,多个字段用英文逗号连接。表内没有的字段不得猜测。

### 基础行情与元信息

`最新交易日`、`交易时间`、`中文简称`、`最新成交价`、`前收盘价`、`今日开盘价`、`今日最高价`、`今日最低价`、`最新均价`、`涨跌`、`涨跌幅`、`成交量`、`成交额`、`交易状态`、`上市日期`、`近1分钟成交额`、`近3分钟成交额`、`近5分钟成交额`、`K线实体涨跌幅`、`K线实体涨跌额`、`换手率`、`量比`、`振幅`、`基于Wind算法的量比`

### 盘口与逐笔

`现量`、`现额`、`买一价`、`买二价`、`买三价`、`买四价`、`买五价`、`卖一价`、`卖二价`、`卖三价`、`卖四价`、`卖五价`、`买一量`、`买二量`、`买三量`、`买四量`、`买五量`、`卖一量`、`卖二量`、`卖三量`、`卖四量`、`卖五量`、`外盘`、`内盘`、`成交笔数`、`委比`

### 资金流向

`当日主力净流入额`、`当日主力净流入占比`、`近5日主力净流入额`、`近5日主力净流入占比`、`近5日主力净流入天数`、`近10日主力净流入额`、`近10日主力净流入占比`、`近10日主力净流入天数`、`近20日主力净流入额`、`近20日主力净流入占比`、`近20日主力净流入天数`、`近60日主力净流入额`、`近60日主力净流入占比`、`近60日主力净流入天数`、`主力挂单买入`、`主力挂单卖出`、`主力撤单买入`、`主力撤单卖出`、`该日机构资金净流入额`、`该日大户资金净流入额`、`该日中户资金净流入额`、`该日散户资金净流入额`

### 盘中异动

`连红天数`、`连续上涨天数`、`火箭发射`、`高台跳水`、`涨停封板`、`跌停封板`、`涨停开板`、`跌停开板`、`涨幅达到3%`、`跌幅达到3%`、`创20日新高`、`创20日新低`

### 盘前盘后

`盘前最新价`、`盘前涨跌额`、`盘前涨跌幅`、`盘前成交额`、`盘前涨速`、`盘后最新价`、`盘后涨跌幅`、`集合竞价涨跌幅`

### 技术指标

`指数平滑异同移动平均`、`DIF快线`、`随机指标K值`、`随机指标D值`、`随机指标J值`、`6周期相对强弱指标`、`12周期相对强弱指标`、`抛物线转向指标`、`布林中轨`、`布林上轨`、`布林下轨`、`5周期移动平均`、`10周期移动平均`、`20周期移动平均`、`60周期移动平均`、`120周期移动平均`、`250日均线`、`5日乖离率`、`36日乖离`、`14周期顺势指标`、`26周期能量指标`、`12周期心理线指标`、`近1分钟涨跌幅`、`近3分钟涨跌幅`、`MACD多头金叉信号`、`MACD空头死叉信号`

### 多周期涨跌幅

`5分钟涨跌幅`、`5日涨跌幅`、`10日涨跌幅`、`20日涨跌幅`、`60日涨跌幅`、`120日涨跌幅`、`250日涨跌幅`、`年初至今涨跌幅`、`上市以来涨跌幅`、`近3年涨跌幅`、`近5年涨跌幅`、`近10年涨跌幅`、`近20年涨跌幅`

### 净值与规模

`流通份额`

### 估值与市值

`发行价`、`总股本`、`市净率`、`市净率(LF)`、`市盈率(TTM)`、`市盈率(LYR)`、`市盈率(预测)`、`总市值1`、`流通市值`、`总市值2`、`52周最高`、`52周最低`、`股息率`、`涨停价`、`跌停价`

### 成分统计

`成分股贡献点数`、`近5分钟贡献度`

### 使用说明

- `总市值1` 不含限售股,`总市值2` 含限售股;用户只说「总市值」时先确认口径。

references/stock.md
# `stock_data` 工具契约

只用于股票:A 股、港股、美股共用本服务。参数名称、类型、必填项、示例与默认值和枚举以本文件各工具的契约为准。

- `search_stocks` 只用于未指定具体股票的筛选;已指定具体股票时使用对应行情或领域工具。
- 行情、K 线、分钟行情和价格指标不得改用 `analytics_data` 节省调用次数。
- `indexes` 逐字取自 `references/stock-indicators.md`;用户未指定字段时省略 `indexes` 走默认值,指定字段时才读取该指标集。
- 市值口径:`总市值1`=不含限售股,`总市值2`=含限售股;口径不明确时先询问。

## 目录

- [工具契约](#工具契约)
- 行情指标集:`references/stock-indicators.md`(仅 `get_stock_price_indicators` 需要)

## 工具契约

### `get_stock_price_indicators`

返回指定股票当前时刻的截面状态,为时点数据,包括:最新成交价、前收盘价、今日开盘价、最高价、最低价、涨跌额与涨跌幅;成交量、成交额、换手率、量比、振幅、委比、买卖一档价量;涨停价、跌停价、总市值、流通市值。另含别名指标 PE(TTM)、PB、股息率及 5 日、20 日、60 日与年初至今涨跌幅,同名指标取值与相应主域工具一致。仅返回时点截面,不含过程序列(当日走势与指定时间范围)。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 一个或多个股票名称或者股票代码,如贵州茅台或600519.SH;多个用英文逗号分隔,单次最多 50 个,超过请分批查询。 |
| `indexes` | 否 | string | — | 默认:"最新交易日,交易时间,最新成交价,前收盘价,今日开盘价,今日最高价,今日最低价,成交量" | 指标字段,多个字段用英文逗号分隔;可选值见 `references/stock-indicators.md`,构造前先读取该文件并逐字复制。 |

### `get_risk_metrics`

返回指定股票基于历史价格序列计算的定量风险指标(金融工程口径),覆盖系统性风险(如 Beta、基准相关系数)、收益质量(如 Jensen Alpha、夏普比率)、波动与损失(如年化波动率、VaR、最大回撤、区间下跌天数)等类别。不含财务安全性比率(如资产负债率、速动比率)与择时信号。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询宁德时代(300750.SZ)过去1年的Beta、年化波动率和最大回撤" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含证券实体、指标名称、日期(或窗口参数)等查询要素。|

### `get_stock_events`

返回指定股票的公司行动与事件数据,为结构化记录而非公告文本。事件类型覆盖:首发上市与再融资(配股、增发、并购重组);分红派息;股本与股东事件(大股东增减持、限售解禁);治理与监管事件(风险警示与 ST 变动、违规处罚、司法诉讼)。仅返回结构化字段,不含公告与新闻文本原文,不含公司当前状态标签。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询贵州茅台(600519.SH)的分红派息历史" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含证券实体、事件类型、日期(或时间范围)等查询要素。|

### `get_stock_kline`

返回指定股票在给定时间范围内的聚合量价序列(K 线),聚合周期由 period 指定(分钟级至年,默认日 K)。每条记录代表一个周期,包含开盘价、收盘价、最高价、最低价、成交量、成交额、均价、换手率。当日盘中分钟走势建议用分钟级行情工具(缺省即最新交易日);锚定当前的相对窗口统计(近 N 日涨跌幅、年初至今涨跌幅、52 周高低)与基准指数对比不在本工具范围。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | Wind代码,格式如 贵州茅台  或 600519.SH,用于标识具体的股票 |
| `begin_date` | 是 | string | — | — | 开始日期:必须显式填写绝对日期,格式 yyyy-MM-dd,如 2026-03-25。 |
| `end_date` | 是 | string | — | — | 结束日期:必须显式填写绝对日期,格式 yyyy-MM-dd,如 2026-03-26。 |
| `period` | 否 | string | 1min / 5min / 10min / 15min / 30min / 60min / 120min / 240min / 1d / 1w / 1mo / 1y / 1q / 6mo | 默认:"1d" | K 线周期。 |
| `count` | 否 | integer | — | 默认 0 | 在开始/结束日期区间内取数的条数(整数):正数从开始日期往后取 N 条,负数从结束日期往前取 N 条,0 取区间全部;不会超出日期区间。 |
| `aftype` | 否 | string | 0 / 1 / 2 | 默认 0 | 复权类型:0=前复权,1=后复权,2=不复权。前复权更常用 |
| `issusp` | 否 | string | — | 默认 1 | 是否包含停牌数据:0=不包含,1=包含 |
| `afdate` | 否 | string | — | — | 复权基准日期,格式 yyyy-MM-dd,如 2026-03-25。通常不需要指定。 |

### `get_stock_basicinfo`

返回指定股票的公司身份与分类档案,为静态数据,包括:简称、代码、曾用名、上市板块与上市日期、行业分类、概念标签、指数成份归属、工商注册信息、主营业务简介,以及当前状态标签(是否 ST、是否已摘牌)。仅返回当前状态,不含状态变动历史、财务数据与股东明细。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询贵州茅台股票的基本档案" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含证券实体、指标名称、日期等查询要素。 |

### `get_stock_equity_holders`

返回指定股票的股本结构与股东构成,为准静态数据,随报告期与权益变动更新,包括:总股本、流通 A 股、限售股、自由流通股本及占比;前十大股东与前十大流通股东持仓及变动;机构股东持股;实际控制人与大股东详情;限售解禁时间表与本期解禁数量。不含市值(由价格与股本派生)。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询贵州茅台(600519.SH)的前十大股东及流通A股占比" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含证券实体、指标名称、日期(或报告期)等查询要素。|

### `get_stock_fundamentals`

返回指定股票的两类基本面数据。第一类为财务原始指标:三大报表科目,偿债、盈利、成长、杠杆等财务比率(如 ROE、毛利率、资产负债率、速动比率),以及行业专项指标(如银行净息差、不良贷款率),时间维度为报告期。第二类为衍生估值指标:PE、PB、PS(TTM 口径)、股息率、总市值与流通市值及其历史分位数,随交易日变动。不含价格序列。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询贵州茅台(600519.SH)2024-12-31的ROE、营业收入和净利润" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含证券实体、指标名称、日期(或报告期)等查询要素。 |

### `get_stock_quote`

返回指定股票的分钟级量价序列,每条记录代表一分钟,包含开盘价、最新成交价、最高价、最低价、成交量、成交额、均价与换手率。时间范围由 begin/end 指定(含首尾),未指定默认最新交易日;单日约 240 条,跨日体积按天数放大,长区间建议改用 K 线工具的聚合周期。仅交易日有数据,非交易日返回空结果。当前时刻截面状态请用行情快照工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `windcode` | 是 | string | — | — | 股票名称或者股票代码,如贵州茅台或600519.SH。 |
| `begin` | 否 | string | — | — | 开始日期,格式 yyyy-MM-dd,如 2026-03-25;未指定默认最新交易日。不可只传 end 不传 begin。 |
| `end` | 否 | string | — | — | 结束日期,格式 yyyy-MM-dd,如 2026-03-25;未指定默认最新交易日(即只传 begin 时返回 begin 至最新交易日的区间)。 |
| `count` | 否 | integer | — | 默认 0 | 在 begin/end 区间内取数的条数(整数):正数从 begin 往后取 N 条,负数从 end 往前取 N 条,0 取区间全部;不会超出区间;未指定 begin/end 时按默认最新交易日计。 |

### `get_stock_technicals`

返回指定股票由行情数据计算的派生指标,日频为主,覆盖三族。相对窗口统计:近 N 日、周、月、年及年初至今涨跌幅,52 周最高最低价,相对基准指数表现,均锚定当前滚动。技术指标:趋势类、成交量类、超买超卖类、波动类(如 MACD、KDJ、RSI、BOLL 等)。技术形态:连续涨跌、创新高或新低检测、突破反转、涨跌停与连板状态等。不含原始行情序列、实时快照与风险统计量。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"查询贵州茅台(600519.SH)最近20日的收盘价与MACD" | 自然语言查询要求,为"实体 + 指标 + 指标参数"三要素的组合超集,应包含证券实体、指标名称、日期等查询要素。|

### `search_stocks`

根据自然语言筛选条件从 A 股及海外股票市场反查股票,返回符合条件的股票代码列表。支持按指标数值(行情、财务、估值、资金、技术等)与业务分类(行业板块、主题概念、主营业务)组合条件,返回的代码可传入各属性查询工具获取具体数据。已指定具体股票时不要调用本工具。

| 参数 | 必填 | 类型 | 枚举 | 示例 / 默认 | 官方说明 |
| --- | --- | --- | --- | --- | --- |
| `question` | 是 | string | — | 示例:"筛选沪深市场市值超500亿且连续5日上涨的股票" | 自然语言筛选条件,为指标与指标参数(运算条件、阈值)的组合,无需指定实体。 |

行情指标集已独立为 `references/stock-indicators.md`,仅构造 `get_stock_price_indicators` 的 `indexes` 参数时读取。
scripts/call-rules.json
{
  "schema_version": 18,
  "description": "Runtime normalization and validation rules for Wind MCP tool calls.",
  "kline_period_map": {
    "1min": "1",
    "5min": "3",
    "10min": "4",
    "15min": "5",
    "30min": "6",
    "60min": "7",
    "120min": "8",
    "240min": "9",
    "1d": "10",
    "1w": "11",
    "1mo": "12",
    "1y": "13",
    "1q": "14",
    "6mo": "15"
  },
  "tool_by_domain": {
    "price": {
      "stock_data": "get_stock_price_indicators",
      "fund_data": "get_fund_price_indicators",
      "index_data": "get_index_price_indicators"
    },
    "kline": {
      "stock_data": "get_stock_kline",
      "fund_data": "get_fund_kline",
      "index_data": "get_index_kline"
    },
    "quote": {
      "stock_data": "get_stock_quote",
      "fund_data": "get_fund_quote",
      "index_data": "get_index_quote"
    }
  },
  "basic": {
    "string_keys": [
      "question",
      "query",
      "windcode",
      "indexes",
      "observation",
      "begin_date",
      "end_date",
      "begin",
      "end",
      "beginDate",
      "endDate",
      "date",
      "tradeDate",
      "afdate"
    ]
  },
  "tool_rules": [
    {
      "name": "question_required",
      "label": "自然语言",
      "tools": [
        "search_stocks",
        "get_stock_basicinfo",
        "get_stock_fundamentals",
        "get_stock_equity_holders",
        "get_stock_events",
        "get_stock_technicals",
        "get_risk_metrics",
        "search_funds",
        "get_fund_info",
        "get_fund_financials",
        "get_fund_holdings",
        "get_fund_performance",
        "get_fund_holders",
        "get_fund_company_info",
        "get_index_basicinfo",
        "get_index_fundamentals",
        "get_index_technicals",
        "get_bond_basicinfo",
        "get_bond_issuer_info",
        "get_bond_market_data",
        "get_bond_financial_data",
        "get_financial_data"
      ],
      "required": ["question"]
    },
    {
      "name": "financial_docs_query_required",
      "label": "金融文档",
      "tools": ["get_company_announcements", "get_financial_news"],
      "required": ["query"]
    },
    {
      "name": "kline",
      "label": "K 线",
      "tools": ["get_stock_kline", "get_fund_kline", "get_index_kline"],
      "required": ["windcode", "begin_date", "end_date"],
      "ordered_dates": [["begin_date", "end_date"]],
      "enum_fields": {
        "period": {
          "values_from": "kline_period_map",
          "message": "字段 'period' 只能是 ${values},日 K 请传 '1d'"
        },
        "aftype": {
          "values": ["0", "1", "2"],
          "message": "字段 'aftype' 只能是 '0'(前复权)、'1'(后复权)或 '2'(不复权)"
        },
        "issusp": {
          "values": ["0", "1"],
          "message": "字段 'issusp' 只能是 '0' 或 '1'"
        }
      },
      "patterns": {
        "count": {
          "pattern": "^-?\\d+$",
          "message": "字段 'count' 只能是整数:正数从开始日期往后取 N 条,负数从结束日期往前取 N 条,0 取区间全部"
        }
      }
    },
    {
      "name": "quote",
      "label": "分钟行情",
      "tools": ["get_stock_quote", "get_fund_quote", "get_index_quote"],
      "required": ["windcode"],
      "ordered_dates": [["begin", "end"]],
      "patterns": {
        "count": {
          "pattern": "^-?\\d+$",
          "message": "字段 'count' 只能是整数:正数从 begin 往后取 N 条,负数从 end 往前取 N 条,0 取区间全部"
        }
      }
    },
    {
      "name": "economic_search",
      "label": "宏观 EDB 搜索指标",
      "tools": ["search_economic_indicator"],
      "allowed": ["question"],
      "required": ["question"]
    },
    {
      "name": "economic_query",
      "label": "宏观 EDB 取数",
      "tools": ["query_economic_indicator_data"],
      "allowed": ["question", "beginDate", "endDate", "observation"],
      "required": ["question"],
      "paired": [["beginDate", "endDate"]],
      "mutually_exclusive": [["observation", "beginDate"], ["observation", "endDate"]],
      "ordered_dates": [["beginDate", "endDate"]],
      "patterns": {
        "observation": {
          "pattern": "^\\d+$",
          "message": "字段 'observation' 只能是数字字符串(近 N 期)"
        }
      },
      "required_one_of": [
        {
          "one_of": [["observation"], ["beginDate", "endDate"]],
          "message": "query_economic_indicator_data 必须显式提供 observation 或 beginDate/endDate(不能只给 question)"
        }
      ]
    }
  ]
}
scripts/cli.mjs
#!/usr/bin/env node
// wind-mcp-skill CLI: thin JSON-envelope wrapper around Wind MCP servers
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, basename, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawn } from 'node:child_process';

// #region 静态:版本、7 个 MCP 地址、路径、HTTP 状态码映射。只含常量,不发网络。
const SKILL_VERSION = '2.0.4';

// 本地 registry: 工具选择可在任何网络调用前失败
const SERVERS = {
  stock_data: {
    endpoint: 'https://mcp.wind.com.cn/vserver_stock_data/mcp/',
    label: 'Wind 股票(选股筛选 + 档案/财务/股本/事件/技术/风险 + 行情/K线/分钟)',
  },
  fund_data: {
    endpoint: 'https://mcp.wind.com.cn/vserver_fund_data/mcp/',
    label: 'Wind 基金(基金筛选 + 档案/财务/持仓/业绩/持有人/公司 + 行情/K线/分钟)',
  },
  index_data: {
    endpoint: 'https://mcp.wind.com.cn/vserver_index_data/mcp/',
    label: 'Wind 指数/板块(档案/基本面/技术 + 行情/K线/分钟)',
  },
  bond_data: {
    endpoint: 'https://mcp.wind.com.cn/vserver_bond_data/mcp/',
    label: 'Wind 债券(基本档案/发债主体/行情估值/主体财务)',
  },
  financial_docs: {
    endpoint: 'https://mcp.wind.com.cn/vserver_financial_docs/mcp/',
    label: 'Wind 金融文档 RAG(公告 / 新闻)',
  },
  economic_data: {
    endpoint: 'https://mcp.wind.com.cn/vserver_economic_data/mcp/',
    label: 'Wind EDB 宏观/行业经济指标',
  },
  analytics_data: {
    endpoint: 'https://mcp.wind.com.cn/vserver_analytics_data/mcp/',
    label: 'Wind 通用分析数据(NL → Wind 数据)',
  },
};

const PORTAL_URL = 'https://aifinmarket.wind.com.cn/#/user/overview';

const SKILL_DIR = dirname(dirname(fileURLToPath(
  import.meta.url)));

const UPDATE_CHECK_PATH = join(SKILL_DIR, 'scripts', 'update-check.mjs');
const TOOL_MANIFEST_PATH = join(SKILL_DIR, 'scripts', 'tool-manifest.json');
const CALL_RULES_PATH = join(SKILL_DIR, 'scripts', 'call-rules.json');

const SKILL_NAME = basename(SKILL_DIR);

const CALL_EXAMPLES = [
  `cli.mjs call stock_data search_stocks '{"question":"筛选沪深市场市值超500亿且连续5日上涨的股票"}'`,
  `cli.mjs call stock_data search_stocks '{"question":"筛选港股中市值超1000亿港元的科技股"}'`,
  `cli.mjs call fund_data search_funds '{"question":"筛选股票型基金中近一年收益率超20%的产品"}'`,
  `cli.mjs call stock_data get_stock_basicinfo '{"question":"600519.SH公司基本档案"}'`,
  `cli.mjs call stock_data get_stock_price_indicators '{"windcode":"600519.SH","indexes":"中文简称,最新成交价,涨跌幅"}'`,
  `cli.mjs call fund_data get_fund_kline '{"windcode":"588200.SH","begin_date":"2026-04-01","end_date":"2026-04-30"}'`,
  `cli.mjs call stock_data get_stock_quote '{"windcode":"AAPL.O","begin":"2026-08-05","end":"2026-08-05","count":-30}'`,
  `cli.mjs call index_data get_index_kline '{"windcode":"000300.SH","begin_date":"2026-04-01","end_date":"2026-04-30"}'`,
  `cli.mjs call financial_docs get_financial_news '{"query":"美联储利率政策","top_k":3}'`,
  `cli.mjs call economic_data search_economic_indicator '{"question":"中国GDP相关指标有哪些"}'`,
  `cli.mjs call economic_data query_economic_indicator_data '{"question":"中国GDP","observation":"10"}'`,
  `cli.mjs call analytics_data get_financial_data '{"question":"查询中国A股市场过去一年的平均成交量"}'`,
];

const PRICE_INDICATOR_TOOLS = new Set(['get_stock_price_indicators', 'get_fund_price_indicators', 'get_index_price_indicators']);
const QUOTE_TOOLS = new Set(['get_stock_quote', 'get_fund_quote', 'get_index_quote']);

const HTTP_ERROR_MAP = {
  401: 'AUTH_ERROR',
  429: 'RATE_LIMIT_ERROR',
  500: 'NETWORK_ERROR',
  502: 'NETWORK_ERROR',
  503: 'NETWORK_ERROR',
  504: 'NETWORK_ERROR',
};
// #endregion 静态

// #region 自动更新:仅 call 成功后触发;今天已成功则跳过;detached 跑 update-check.mjs,不阻塞取数。

function todayKey() {
  return new Date().toISOString().slice(0, 10);
}

function normalizePath(value) {
  const normalized = resolve(value).replace(/\\/g, '/');
  return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}

function updateScope() {
  const globalRoot = normalizePath(join(homedir(), '.agents', 'skills'));
  const skillDir = normalizePath(SKILL_DIR);
  return skillDir.startsWith(globalRoot + '/') ? 'global' : 'project';
}

function updateStateFile() {
  return join(SKILL_DIR, 'scripts', 'update-state.json');
}

function readUpdateState() {
  try {
    const stateFile = updateStateFile();
    if (!existsSync(stateFile)) return null;
    return JSON.parse(readFileSync(stateFile, 'utf8'));
  } catch {
    return null;
  }
}

function writeUpdateStatePatch(patch) {
  const stateFile = updateStateFile();
  mkdirSync(dirname(stateFile), { recursive: true });
  const state = { ...(readUpdateState() || {}), ...patch };
  writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n');
}

function alreadyUpdatedToday() {
  try {
    const state = readUpdateState();
    return state && state.date === todayKey() && state.status === 'success';
  } catch {
    return false;
  }
}

function markSkillUsed() {
  writeUpdateStatePatch({
    lastUsedAt: new Date().toISOString(),
    lastUsedPid: process.pid,
  });
}

function triggerUpdateCheck() {
  try {
    if (!existsSync(UPDATE_CHECK_PATH)) return;
    if (alreadyUpdatedToday()) return;
    markSkillUsed();
    const tmpDir = join(homedir(), '.cache', 'wind-aifinmarket');
    mkdirSync(tmpDir, { recursive: true });
    const runnerPath = join(tmpDir, `update-check-${SKILL_NAME}-${process.pid}.mjs`);
    copyFileSync(UPDATE_CHECK_PATH, runnerPath);
    const child = spawn('node', [runnerPath, SKILL_DIR], { detached: true, stdio: 'ignore', windowsHide: true });
    child.on('error', () => { /* ignore spawn failures; update must not block CLI */ });
    child.unref();
  } catch { }
}
// #endregion 自动更新

// #region 信封:成功写 MCP result + cli_meta;失败统一写 {ok:false,code,message}。Agent 只读 stdout。
function normalizeSuccessPayload(value, path = '$', state = { warnings: [], tables: [], invalidPaths: [] }, dataCell = false) {
  if (dataCell && value === 'INVALID') {
    state.invalidPaths.push(path);
    return null;
  }
  if (Array.isArray(value)) {
    return value.map((item, index) => normalizeSuccessPayload(item, `${path}[${index}]`, state, dataCell));
  }
  if (!value || typeof value !== 'object') return value;

  const normalized = {};
  for (const [key, item] of Object.entries(value)) {
    const isStructuredDataArray = Array.isArray(item) && (key === 'rows' || key === 'value');
    normalized[key] = normalizeSuccessPayload(item, `${path}.${key}`, state, dataCell || isStructuredDataArray);
  }
  if (Array.isArray(value.rows)) {
    state.tables.push({ path, actual_row_count: value.rows.length });
  }
  if (Object.hasOwn(value, 'excelTotalCount')) {
    state.warnings.push({
      code: 'UNRELIABLE_DECLARED_COUNT',
      path: `${path}.excelTotalCount`,
      message: 'excelTotalCount 仅保留为后端原始字段,不得据此判断结果总数或完整性。',
    });
  }
  return normalized;
}

// 保留 MCP result 外层兼容性;只清洗可解析的 JSON 文本并附加机器可读安全元数据。
function normalizeCallSuccess(result, context = {}) {
  const output = result && typeof result === 'object' ? structuredClone(result) : result;
  const state = { warnings: [], tables: [], invalidPaths: [] };
  if (output && Array.isArray(output.content)) {
    for (const item of output.content) {
      if (item?.type !== 'text' || typeof item.text !== 'string') continue;
      try {
        const parsed = JSON.parse(item.text);
        item.text = JSON.stringify(normalizeSuccessPayload(parsed, '$', state));
      } catch {
        // 非 JSON 文本按后端原文透传。
      }
    }
  }
  if (state.invalidPaths.length) {
    state.warnings.push({
      code: 'BACKEND_INVALID_AS_NULL',
      count: state.invalidPaths.length,
      paths: state.invalidPaths.slice(0, 100),
      truncated: state.invalidPaths.length > 100,
      message: '结构化数据区中的后端字符串 INVALID 已转换为 null;表示缺失或不适用,禁止按 0 参与计算。',
    });
  }
  if (output && typeof output === 'object') {
    output.cli_meta = {
      schema_version: '1.0',
      server_type: context.server_type || null,
      tool_name: context.tool_name || null,
      completeness: state.warnings.some(warning => warning.code === 'UNRELIABLE_DECLARED_COUNT') ? 'unknown' : 'not_asserted',
      tables: state.tables,
      warnings: state.warnings,
    };
  }
  return output;
}

function writeRawCallSuccess(result, context = {}) {
  process.stdout.write(JSON.stringify(normalizeCallSuccess(result, context), null, 2) + '\n');
}

function writePlainSuccess(data) {
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
}

const DEFAULT_ERROR_MESSAGES = Object.freeze({
  AUTH_ERROR: '认证失败,请检查 API Key',
  PARAM_TYPE_ERROR: '参数类型错误,请检查字段类型',
  USAGE_ERROR: '命令用法错误,请检查输入参数',
  PARAMS_FILE_ERROR: '参数文件读取失败,请检查文件路径和内容',
  INVALID_PARAMS_JSON: '参数格式错误,params 必须是 JSON 对象',
  ROUTE_ERROR: '工具路由失败,请检查 server_type 和 tool_name',
  PARAM_VALIDATION_ERROR: '参数校验失败,请检查字段名和取值',
  PARAM_CONFLICT_ERROR: '参数存在冲突,请检查输入组合',
  RATE_LIMIT_ERROR: '请求过于频繁,请稍后重试',
  NETWORK_ERROR: '服务暂时不可用,请稍后重试',
  TOOL_RUNTIME_ERROR: '响应解析失败,请稍后重试',
  SETUP_ERROR: '本地配置缺失或无效,请检查技能配置',
  UNKNOWN: '调用失败,请稍后重试',
});

const MAPPED_ERROR_MESSAGE_CODES = new Set(
  Object.keys(DEFAULT_ERROR_MESSAGES).filter((code) => code !== 'UNKNOWN'),
);

function normalizeErrorMessage(code, detail, metadata = {}) {
  if (!MAPPED_ERROR_MESSAGE_CODES.has(code)) {
    if (typeof metadata.error_message === 'string' && metadata.error_message.trim()) {
      return metadata.error_message.trim();
    }
    if (typeof detail === 'string' && detail.trim()) {
      return detail.trim().slice(0, 2000);
    }
    return DEFAULT_ERROR_MESSAGES.UNKNOWN;
  }
  if (typeof detail === 'string' && detail.trim()) {
    return detail.trim().slice(0, 500);
  }
  return DEFAULT_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGES.UNKNOWN;
}

function writeErrorEnvelope(code, detail, metadata = {}) {
  const envelope = {
    ok: false,
    code,
    message: normalizeErrorMessage(code, detail, metadata),
  };
  process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
}

function die(code, detail = null, exitCode = 1, metadata = {}) {
  writeErrorEnvelope(code, detail, metadata);
  process.exit(exitCode);
}

function exitWithUsage(usage, exitCode = 0) {
  die('USAGE_ERROR', `USAGE:\n${usage}`, exitCode);
}
// #endregion 信封

// #region 认证:Key 顺序为 ~/.wind-aifinmarket/config > skill config.json > 环境变量 WIND_API_KEY。
function maskKey(key) {
  if (!key || key.length < 8) return '***';
  return key.slice(0, 4) + '***' + key.slice(-4);
}

// dotenv 解析: 兼容注释 / 引号 / export 前缀
function parseDotenv(content) {
  const env = {};
  for (const rawLine of content.split('\n')) {
    let line = rawLine.replace(/^\uFEFF/, '').trim();
    if (!line || line.startsWith('#')) continue;
    if (line.startsWith('export ')) line = line.slice(7).trim();
    const eq = line.indexOf('=');
    if (eq <= 0) continue;
    const key = line.slice(0, eq).trim();
    let val = line.slice(eq + 1).trim();
    if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
      val = val.slice(1, -1);
    } else {
      const hashIdx = val.indexOf(' #');
      if (hashIdx >= 0) val = val.slice(0, hashIdx).trim();
    }
    env[key] = val;
  }
  return env;
}

function getApiKey() {
  const globalConfig = join(homedir(), '.wind-aifinmarket', 'config');
  if (existsSync(globalConfig)) {
    try {
      const env = parseDotenv(readFileSync(globalConfig, 'utf8'));
      const key = env.WIND_API_KEY?.trim();
      if (key) return key;
    } catch { }
  }

  const localConfig = join(SKILL_DIR, 'config.json');
  if (existsSync(localConfig)) {
    try {
      const cfg = JSON.parse(readFileSync(localConfig, 'utf8'));
      const key = typeof cfg.wind_api_key === 'string' ? cfg.wind_api_key.trim() : '';
      if (key) return key;
    } catch { }
  }

  const envKey = process.env.WIND_API_KEY?.trim();
  if (envKey) return envKey;

  die('AUTH_ERROR', 'WIND_API_KEY 未配置(CLI 已完整检查:用户全局配置 > Skill 本地配置 > 环境变量)');
}
// #endregion 认证

// #region 路由:校验 server_type、tool_name 是否在 SERVERS 与 tool-manifest.json。非法则 ROUTE_ERROR。
function getServer(server_type) {
  const server = SERVERS[server_type];
  if (!server) {
    die('ROUTE_ERROR', `未知 server_type: ${server_type}. 可用: ${Object.keys(SERVERS).join(' / ')}`);
  }
  return server;
}

function loadToolManifest() {
  try {
    // tool-manifest.json is the authority for legal server_type + tool_name combinations.
    const manifest = JSON.parse(readFileSync(TOOL_MANIFEST_PATH, 'utf8'));
    if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
      throw new Error('manifest 顶层必须是对象');
    }
    for (const [serverType, tools] of Object.entries(manifest)) {
      if (!SERVERS[serverType]) {
        throw new Error(`manifest 包含未知 server_type: ${serverType}`);
      }
      if (!Array.isArray(tools) || tools.some(tool => typeof tool !== 'string' || !tool)) {
        throw new Error(`manifest 中 ${serverType} 的工具清单必须是非空字符串数组`);
      }
    }
    for (const serverType of Object.keys(SERVERS)) {
      if (!Array.isArray(manifest[serverType])) {
        throw new Error(`manifest 缺少 server_type: ${serverType}`);
      }
    }
    return manifest;
  } catch (err) {
    die('UNKNOWN', `工具清单读取失败: ${err.message}`);
  }
}

function validateToolSelection(server_type, toolName) {
  getServer(server_type);
  const manifest = loadToolManifest();
  const tools = manifest[server_type];
  if (!tools.includes(toolName)) {
    die('ROUTE_ERROR', `工具名 "${toolName}" 不属于 server_type "${server_type}"。`);
  }
}
// #endregion 路由

// #region 规则加载:读 call-rules.json,得到 K 线周期映射、按域改写工具名、参数校验规则。
function readCallRules() {
  try {
    return JSON.parse(readFileSync(CALL_RULES_PATH, 'utf8'));
  } catch (err) {
    die('UNKNOWN', `调用规则读取失败: ${err.message}`);
  }
}

function prepareNormalizationRules(rules) {
  return {
    klinePeriodMap: new Map(Object.entries(rules.kline_period_map || {})),
    toolByDomain: rules.tool_by_domain || {},
  };
}

const CALL_RULES = readCallRules();
const NORMALIZATION_RULES = prepareNormalizationRules(CALL_RULES);
const KLINE_PERIOD_MAP = NORMALIZATION_RULES.klinePeriodMap;
const PUBLIC_KLINE_PERIODS = new Set(KLINE_PERIOD_MAP.keys());
const KLINE_PERIODS = new Set(KLINE_PERIOD_MAP.values());
const TOOL_BY_DOMAIN = NORMALIZATION_RULES.toolByDomain;

const TOOL_VALIDATION_RULES = {
  basic: CALL_RULES.basic || {},
  toolRules: Array.isArray(CALL_RULES.tool_rules) ? CALL_RULES.tool_rules : [],
};
const KLINE_TOOLS = new Set(TOOL_VALIDATION_RULES.toolRules.find(rule => rule.name === 'kline')?.tools || []);
// #endregion 规则加载

// #region 规范化:整理 windcode/indexes/period。不给中文名称猜交易所后缀。
function normalizeIndexes(indexes) {
  if (typeof indexes !== 'string') return indexes;
  return indexes.split(',').map((item) => item.trim()).filter(Boolean).join(',');
}

function normalizeWindcode(windcode) {
  if (typeof windcode !== 'string') return windcode;
  const raw = windcode.trim();
  const upper = raw.toUpperCase();
  // Keep natural-language names untouched. Wind's backend NER is responsible
  // for resolving names/aliases; the CLI must not guess exchange suffixes.
  if (/[\u4e00-\u9fff]/.test(raw)) return raw;
  if (/^0\d{4}\.HK$/.test(upper)) return upper.slice(1);
  if (/^\d{4}\.HK$/.test(upper)) return upper;
  if (/^\d{6}\.(SH|SZ|BJ|OF)$/.test(upper)) return upper;
  if (/^[A-Z]{1,5}\.(O|N|A|HK|SH|SZ|BJ)$/.test(upper)) return upper;
  return raw;
}

function toolFamily(toolName) {
  if (PRICE_INDICATOR_TOOLS.has(toolName)) return 'price';
  if (KLINE_TOOLS.has(toolName)) return 'kline';
  if (QUOTE_TOOLS.has(toolName)) return 'quote';
  return null;
}

function normalizeCall(server_type, toolName, args) {
  const family = toolFamily(toolName);
  if (family) toolName = TOOL_BY_DOMAIN[family]?.[server_type] || toolName;
  const normalizedArgs = { ...args };
  const normalizationErrors = [];
  if (typeof normalizedArgs.indexes === 'string') normalizedArgs.indexes = normalizeIndexes(normalizedArgs.indexes);
  if (typeof normalizedArgs.windcode === 'string') normalizedArgs.windcode = normalizeWindcode(normalizedArgs.windcode);
  // count 是整型字段:把整数字符串收敛成 number,非整数原样留给 patterns 校验拦截。
  if (typeof normalizedArgs.count === 'string' && /^-?\d+$/.test(normalizedArgs.count.trim())) {
    normalizedArgs.count = Number(normalizedArgs.count.trim());
  }
  if (KLINE_TOOLS.has(toolName) && normalizedArgs.period === undefined) normalizedArgs.period = '1d';
  if (typeof normalizedArgs.period === 'string') {
    const key = normalizedArgs.period.trim();
    const backendPeriod = KLINE_PERIOD_MAP.get(key);
    normalizedArgs.period = backendPeriod || key;
    if (!backendPeriod && KLINE_PERIODS.has(key)) {
      normalizationErrors.push({
        message: `字段 'period' 只能是 ${Array.from(PUBLIC_KLINE_PERIODS).join('/')},日 K 请传 '1d'`,
        field: 'period',
        issue: 'invalid_enum',
        actual: key,
        allowed_values: Array.from(PUBLIC_KLINE_PERIODS),
      });
    }
  }
  return { server_type, toolName, args: normalizedArgs, normalizationErrors };
}
// #endregion 规范化

// #region 校验:按 call-rules 查必填、枚举、成对/互斥字段、日期顺序。发网络前拦住非法参数。
function validateBasicParams(params) {
  const errors = [];
  if (!params || typeof params !== 'object' || Array.isArray(params)) {
    return [{
      code: 'PARAM_TYPE_ERROR',
      message: 'params 必须是 JSON object',
      field: 'params',
      issue: 'invalid_type',
      expected_type: 'object',
      actual_type: Array.isArray(params) ? 'array' : typeof params,
    }];
  }

  const basic = TOOL_VALIDATION_RULES.basic;
  for (const key of basic.string_keys || []) {
    if (!(key in params)) continue;
    if (typeof params[key] !== 'string') {
      errors.push({ message: `字段 '${key}' 必须是字符串`, field: key, issue: 'invalid_type', expected_type: 'string', actual_type: Array.isArray(params[key]) ? 'array' : typeof params[key] });
    } else if (params[key].trim().length === 0) {
      errors.push({ message: `字段 '${key}' 不能为空或全空白`, field: key, issue: 'empty_value', expected: 'non-empty string' });
    }
  }
  return errors;
}

function hasParamValue(params, key) {
  return params[key] !== undefined && params[key] !== null && params[key] !== '';
}

function resolveValidationValues(fieldRule) {
  if (Array.isArray(fieldRule.values)) return fieldRule.values.map(String);
  if (fieldRule.values_from === 'kline_period_map') return Array.from(KLINE_PERIODS).map(String);
  return [];
}

function resolveValidationDisplayValues(fieldRule) {
  if (fieldRule.values_from === 'kline_period_map') return Array.from(PUBLIC_KLINE_PERIODS).map(String);
  return resolveValidationValues(fieldRule);
}

function renderValidationMessage(template, values) {
  return String(template || '').replace('${values}', values.join('/'));
}

function validationErrorMessage(error) {
  return typeof error === 'string' ? error : error.message;
}

function validationErrorCode(error) {
  return typeof error === 'object' && error?.code ? error.code : null;
}

function validateToolParams(toolName, params) {
  const errors = [];
  const rules = TOOL_VALIDATION_RULES.toolRules.filter(rule => Array.isArray(rule.tools) && rule.tools.includes(toolName));

  for (const rule of rules) {
    const ruleLabel = rule.label || rule.name || toolName;
    if (Array.isArray(rule.allowed)) {
      const allowedKeys = new Set(rule.allowed);
      for (const key of Object.keys(params)) {
        if (!allowedKeys.has(key)) errors.push({ message: `${ruleLabel} 工具不支持字段 '${key}'`, field: key, issue: 'unknown_field', allowed_fields: [...allowedKeys] });
      }
    }

    for (const key of rule.required || []) {
      if (!hasParamValue(params, key)) errors.push({ message: `${ruleLabel} 工具缺少必填字段 '${key}'`, field: key, issue: 'missing_required', required_fields: rule.required || [] });
    }

    for (const [field, fieldRule] of Object.entries(rule.enum_fields || {})) {
      if (!(field in params)) continue;
      const values = resolveValidationValues(fieldRule);
      if (!values.includes(String(params[field]))) {
        const displayValues = resolveValidationDisplayValues(fieldRule);
        errors.push({ message: renderValidationMessage(fieldRule.message, displayValues), field, issue: 'invalid_enum', actual: params[field], allowed_values: displayValues });
      }
    }

    for (const fields of rule.paired || []) {
      const present = fields.filter(key => hasParamValue(params, key));
      if (present.length > 0 && present.length < fields.length) {
        errors.push({ message: `字段 '${fields.join("' 和 '")}' 应成对填写`, fields, issue: 'incomplete_pair', expected_fields: fields });
      }
    }

    for (const fields of rule.mutually_exclusive || []) {
      const present = fields.filter(key => hasParamValue(params, key));
      if (present.length > 1) {
        errors.push({ message: `字段 '${fields.join('/')}' 互斥,不应同时填写`, fields, issue: 'mutually_exclusive' });
      }
    }

    for (const [startKey, endKey] of rule.ordered_dates || []) {
      if (params[startKey] && params[endKey] && params[startKey] > params[endKey]) {
        errors.push({ message: `字段 '${startKey}' 不能晚于 '${endKey}'`, fields: [startKey, endKey], issue: 'invalid_order', expected: `${startKey} <= ${endKey}` });
      }
    }

    for (const [field, patternRule] of Object.entries(rule.patterns || {})) {
      if (!(field in params)) continue;
      const pattern = new RegExp(patternRule.pattern);
      if (!pattern.test(String(params[field]))) {
        errors.push({ message: patternRule.message || `字段 '${field}' 格式不合法`, field, issue: 'invalid_format', actual: params[field], expected_pattern: patternRule.pattern });
      }
    }

    for (const conditional of rule.required_one_of_when || []) {
      if (!conditional.values?.map(String).includes(String(params[conditional.field]))) continue;
      const satisfied = conditional.one_of?.some(group => group.every(key => hasParamValue(params, key)));
      if (!satisfied) errors.push({ message: conditional.message || `字段 '${conditional.field}' 当前取值缺少配套参数`, field: conditional.field, issue: 'missing_conditional_fields', one_of: conditional.one_of });
    }

    for (const requirement of rule.required_one_of || []) {
      const satisfied = requirement.one_of?.some(group => group.every(key => hasParamValue(params, key)));
      if (!satisfied) errors.push({ message: requirement.message || `${ruleLabel} 工具缺少一组必填字段`, issue: 'missing_one_of', one_of: requirement.one_of });
    }
  }
  return errors;
}
// #endregion 校验

// #region MCP:裸 HTTP JSON-RPC + SSE。先 initialize 再 tools/call。本地/网络错误由 CLI 收口,接口错误统一 backend_error。
function parseSSE(text) {
  const trimmed = text.trim();
  // 后端正常 SSE, 部分错误场景纯 JSON
  if (trimmed.startsWith('{')) {
    try {
      return JSON.parse(trimmed);
    } catch { }
  }
  const lines = text.split(/\r?\n/);
  let last = null;
  for (const line of lines) {
    if (line.startsWith('data: ')) last = line.slice(6);
  }
  if (last) {
    try {
      return JSON.parse(last);
    } catch (e) {
      throw new Error(`SSE data 行 JSON 解析失败:${e.message}。原文前 200 字符:${text.slice(0, 200)}`);
    }
  }
  throw new Error(`响应格式无法识别(既非 SSE 也非纯 JSON)。原文前 200 字符:${text.slice(0, 200)}`);
}

async function fetchWithRetry(fetchFn, url, optionsOrFactory, {
  attempts = 3,
  delaysMs = [300, 1000],
  onAttemptError = null,
} = {}) {
  let lastError;
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      const options = typeof optionsOrFactory === 'function'
        ? optionsOrFactory(attempt)
        : optionsOrFactory;
      return await fetchFn(url, options);
    } catch (err) {
      lastError = err;
      onAttemptError?.(err, attempt, attempts);
      const delayMs = delaysMs[Math.min(attempt - 1, delaysMs.length - 1)] || 0;
      if (attempt < attempts && delayMs > 0) {
        await new Promise(resolve => setTimeout(resolve, delayMs));
      }
    }
  }
  throw lastError;
}

async function mcpRequest(server_type, method, params, {
  timeoutMs = 60_000,
} = {}) {
  const server = getServer(server_type);
  const apiKey = getApiKey();
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    Accept: 'application/json, text/event-stream',
    'Content-Type': 'application/json',
  };

  const body = JSON.stringify({
    jsonrpc: '2.0',
    id: Date.now(),
    method,
    params
  });
  const dieInterfaceError = (message) => {
    die('backend_error', null, 1, {
      error_message: String(message ?? '').slice(0, 2000),
    });
  };
  let resp;
  try {
    resp = await fetchWithRetry(
      fetch,
      server.endpoint,
      () => ({
        method: 'POST',
        headers,
        body,
        signal: AbortSignal.timeout(timeoutMs),
      }),
      {
        attempts: 3,
        delaysMs: [300, 1000],
        onAttemptError: process.env.WIND_DEBUG === '1'
          ? (err, attempt, total) => {
            const causeCode = err?.cause?.code || err?.code || 'UNKNOWN_CAUSE';
            process.stderr.write(`[wind-mcp fetch retry ${attempt}/${total}] ${causeCode}: ${err?.message || err}\n`);
          }
          : null,
      },
    );
  } catch {
    die('NETWORK_ERROR');
  }

  if (!resp.ok) {
    await resp.text().catch(() => '');
    die(HTTP_ERROR_MAP[resp.status] || 'NETWORK_ERROR');
  }

  const text = await resp.text();
  let payload;
  try {
    payload = parseSSE(text);
  } catch (err) {
    die('TOOL_RUNTIME_ERROR', `${err.message} (server=${server_type})`);
  }

  if (payload.error) {
    const msg = typeof payload.error === 'string'
      ? payload.error
      : (payload.error.message || JSON.stringify(payload.error));
    dieInterfaceError(msg);
  }

  if (payload.result?.isError) {
    const msg = payload.result.content?.[0]?.text || JSON.stringify(payload.result);
    dieInterfaceError(msg);
  }

  // 部分工具把业务错误包在 content[0].text 的 JSON 字符串里, 必须二次解析
  const innerText = payload.result?.content?.[0]?.text;
  if (typeof innerText === 'string') {
    let inner;
    try {
      inner = JSON.parse(innerText);
    } catch {
      inner = null;
    }
    if (inner) {
      if (typeof inner.mcp_tool_error_code === 'number' && inner.mcp_tool_error_code !== 0) {
        const msg = inner.mcp_tool_error_msg || JSON.stringify(inner);
        dieInterfaceError(msg);
      }
      if (inner.error && (inner.error.code || inner.error.message)) {
        const errorMessage = inner.error.message || JSON.stringify(inner.error);
        dieInterfaceError(errorMessage);
      }
      if (inner?.data && typeof inner.data === 'object') {
        const numericCode = typeof inner.data.code === 'number'
          ? inner.data.code
          : (typeof inner.data.code === 'string' && /^\d+$/.test(inner.data.code.trim()) ? Number(inner.data.code) : null);
        const isSuccessCode = numericCode === 0
          || (numericCode !== null && numericCode >= 200 && numericCode < 300);
        if (numericCode !== null && !isSuccessCode) {
          dieInterfaceError(typeof inner.data.message === 'string' ? inner.data.message : JSON.stringify(inner.data));
        }
      }
    }
  }
  return payload.result;
}

async function mcpInitializeAndCall(server_type, method, params) {
  await mcpRequest(server_type, 'initialize', {
    protocolVersion: '2025-03-26',
    capabilities: {},
    clientInfo: {
      name: SKILL_NAME,
      version: SKILL_VERSION
    },
  }, {
    timeoutMs: 30_000
  });

  return mcpRequest(server_type, method, params, {
    timeoutMs: 600_000,
  });
}
// #endregion MCP

// #region 命令:call 取数;list-tools 拉 schema;setup-key / open-portal 配 Key;diagnose 看更新状态。
function loadParamsInput(paramsInput) {
  if (!paramsInput.startsWith('@')) {
    return { jsonText: paramsInput, source: 'inline' };
  }

  const fileArg = paramsInput.slice(1);
  if (!fileArg) {
    const error = new Error('@file 缺少文件路径');
    error.code = 'PARAMS_FILE_ERROR';
    error.file = fileArg;
    throw error;
  }

  const filePath = resolve(process.cwd(), fileArg);
  try {
    const jsonText = readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
    return { jsonText, source: 'file', filePath };
  } catch (cause) {
    const error = new Error(`无法读取 params 文件:${filePath} (${cause.code || cause.message})`);
    error.code = 'PARAMS_FILE_ERROR';
    error.file = filePath;
    error.cause = cause;
    throw error;
  }
}

async function cmdCall(server_type, toolName, paramsInput) {
  if (!server_type || !toolName || !paramsInput) {
    exitWithUsage(
      `用法:call <server_type> <tool_name> '<params_json>|@params_file'\n` +
      `可用 server_type: ${Object.keys(SERVERS).join(' / ')}\n` +
      `典型:\n  ${CALL_EXAMPLES.join('\n  ')}`,
      1,
    );
  }

  let paramsSource;
  try {
    paramsSource = loadParamsInput(paramsInput);
  } catch (e) {
    die('PARAMS_FILE_ERROR', e.message);
  }

  let args;
  try {
    args = JSON.parse(paramsSource.jsonText);
  } catch (e) {
    const sourceDetail = paramsSource.source === 'file'
      ? `文件:${paramsSource.filePath}`
      : `原文:${paramsSource.jsonText.slice(0, 200)}`;
    die('INVALID_PARAMS_JSON', `params JSON 解析失败:${e.message} | ${sourceDetail}`);
  }

  if (!args || typeof args !== 'object' || Array.isArray(args)) {
    die('PARAM_TYPE_ERROR', 'params 必须是 JSON object');
  }

  let normalizationErrors;
  ({ server_type, toolName, args, normalizationErrors } = normalizeCall(server_type, toolName, args));
  validateToolSelection(server_type, toolName);

  const validationErrors = [...normalizationErrors, ...validateBasicParams(args)];
  const paramsShapeInvalid = validationErrors.some(error => validationErrorCode(error) === 'PARAM_TYPE_ERROR' && error.field === 'params');
  if (!paramsShapeInvalid) validationErrors.push(...validateToolParams(toolName, args));
  if (validationErrors.length > 0) {
    const explicitCode = validationErrors.map(validationErrorCode).find(Boolean);
    const messages = validationErrors.map(validationErrorMessage);
    const hasTypeError = validationErrors.some(error => typeof error === 'object' && error?.issue === 'invalid_type');
    die(explicitCode || (hasTypeError ? 'PARAM_TYPE_ERROR' : 'PARAM_VALIDATION_ERROR'), messages.join(';'));
  }

  const result = await mcpInitializeAndCall(server_type, 'tools/call', {
    name: toolName,
    arguments: args,
    _meta: { clientVersion: SKILL_VERSION },
  });
  return {
    server_type,
    tool: toolName,
    result,
  };
}

async function cmdListTools(server_type) {
  if (!server_type) {
    exitWithUsage(
      `用法:list-tools <server_type>\n` +
      `可用 server_type: ${Object.keys(SERVERS).join(' / ')}`,
      1,
    );
  }
  getServer(server_type);
  const result = await mcpInitializeAndCall(server_type, 'tools/list', {});
  return { server_type, ...result };
}

async function cmdSetupKey(...rawArgs) {
  const key = rawArgs[0];

  if (!key || key.startsWith('--')) {
    exitWithUsage(
      `用法:cli.mjs setup-key <KEY> --scope <global|skill>\n\n` +
      `scope: global=全局共享;skill=仅当前 skill。调用前先让用户选择。`,
      1,
    );
  }

  let scope = null;
  for (let i = 1; i < rawArgs.length; i++) {
    const a = rawArgs[i];
    if (a === '--scope' && rawArgs[i + 1]) {
      scope = rawArgs[i + 1];
      break;
    }
    if (a.startsWith('--scope=')) {
      scope = a.slice(8);
      break;
    }
  }

  if (!scope) {
    exitWithUsage(
      `setup-key 缺 --scope 参数。\n\n` +
      `先让用户选择 global 或 skill,再重试:cli.mjs setup-key ${maskKey(key)} --scope <global|skill>`,
      1,
    );
  }

  if (!['global', 'skill'].includes(scope)) {
    die('SETUP_ERROR', `setup-key 未知 scope: ${scope} (可选: global / skill)`);
  }

  let file;
  try {
    if (scope === 'global') {
      const dir = join(homedir(), '.wind-aifinmarket');
      if (!existsSync(dir)) mkdirSync(dir, {
        recursive: true
      });
      file = join(dir, 'config');
      let lines = [];
      if (existsSync(file)) {
        lines = readFileSync(file, 'utf8').split('\n')
          .filter(l => l.length > 0 && !/^\s*(export\s+)?WIND_API_KEY\s*=/.test(l));
      }
      lines.push(`WIND_API_KEY=${key}`);
      writeFileSync(file, lines.join('\n') + '\n', {
        mode: 0o600
      });
    } else {
      file = join(SKILL_DIR, 'config.json');
      writeFileSync(file, JSON.stringify({ wind_api_key: key }, null, 2) + '\n', { mode: 0o600 });
    }
  } catch (err) {
    die('SETUP_ERROR', `配置写入失败 (scope=${scope}, path=${file || 'n/a'}): ${err.message}`);
  }

  return {
    scope,
    path: file,
    key_masked: maskKey(key),
    next: '现在可以重试原 Wind 调用',
  };
}

async function cmdOpenPortal() {
  const platform = process.platform;
  let bin, args;
  if (platform === 'darwin') {
    bin = 'open';
    args = [PORTAL_URL];
  } else if (platform === 'win32') {
    bin = 'cmd';
    args = ['/c', 'start', '', PORTAL_URL];
  } else {
    bin = 'xdg-open';
    args = [PORTAL_URL];
  }

  let spawnError = null;
  try {
    const child = spawn(bin, args, {
      stdio: 'ignore',
      detached: true,
      windowsHide: true
    });
    child.unref();
    spawnError = await new Promise((resolve) => {
      child.once('error', resolve);
      setTimeout(() => resolve(null), 300);
    });
  } catch (err) {
    spawnError = err;
  }

  const data = {
    url: PORTAL_URL,
    platform,
    spawn_command: `${bin} ${args.join(' ')}`,
    flow_note: '未登录时会自动跳转到登录页(/#/login);登录完成后回到 overview 页面即可获取 API Key。',
    fallback_message: `如果浏览器没有自动弹出,请手动访问:${PORTAL_URL}`,
  };
  if (spawnError) {
    die('SETUP_ERROR', `本地无法启动浏览器: ${spawnError.message} | 用户应手动打开 ${data.url}`);
  }
  return data;
}

// 诊断: 输出自动更新状态
async function cmdDiagnose() {
  let updateState = null;
  try {
    const stateFile = updateStateFile();
    if (existsSync(stateFile)) {
      updateState = JSON.parse(readFileSync(stateFile, 'utf8'));
    }
  } catch {
    updateState = { status: 'unreadable' };
  }
  return {
    platform: process.platform,
    node_pid: process.pid,
    update_scope: updateScope(),
    update_state_file: updateStateFile(),
    update_state: updateState,
    next_update_needed: !alreadyUpdatedToday(),
  };
}
// #endregion 命令

// #region 主入口:IS_MAIN 避免测试 import 时跑副作用。无参打 USAGE;仅 call 成功才触发更新检查。
const IS_MAIN = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;

if (IS_MAIN) runMain();

function runMain() {
  const [cmd, ...args] = process.argv.slice(2);

  const USAGE =
    `wind-mcp-skill\n` +
    `访问万得 Wind 金融数据(按数据域分类调用)\n\n` +
    `用法:\n` +
    `  cli.mjs call <server_type> <tool_name> '<params_json>|@params_file'\n` +
    `  cli.mjs list-tools <server_type>                    # 获取后端官方工具描述和 inputSchema\n` +
    `  cli.mjs open-portal                                # 打开万得开发者中心拿 API Key\n` +
    `  cli.mjs setup-key <KEY> --scope <global|skill>     # 配置 API Key(先问用户存放位置)\n\n` +
    `可用 server_type:\n` +
    Object.entries(SERVERS).map(([k, v]) => `  ${k.padEnd(20)}${v.label}`).join('\n') + '\n\n' +
    `典型:\n` +
    `  ${CALL_EXAMPLES.join('\n  ')}`;

  const commands = {
    call: () => cmdCall(args[0], args[1], args[2]),
    'list-tools': () => cmdListTools(args[0]),
    'open-portal': () => cmdOpenPortal(),
    'setup-key': () => cmdSetupKey(...args),
    diagnose: () => cmdDiagnose(),
  };

  if (!cmd) {
    // help: 直接输出 USAGE 纯文本
    process.stdout.write(USAGE + '\n');
    process.exit(0);
  }

  if (!commands[cmd]) {
    die('USAGE_ERROR', `未知命令: ${cmd}\nUSAGE:\n${USAGE}`);
  }

  commands[cmd]()
    .then((data) => {
      if (cmd === 'call') {
        // call: 透传 result 内容 (parse JSON if applicable, else raw text)
        writeRawCallSuccess(data?.result, { server_type: data?.server_type, tool_name: data?.tool });
        setTimeout(triggerUpdateCheck, 0);
      } else {
        // open-portal / setup-key: 直接输出结构化数据 (无 envelope 包裹)
        writePlainSuccess(data);
      }
    })
    .catch((err) => {
      die('UNKNOWN', `执行失败: ${err.message || err}${err.stack ? ' | stack: ' + err.stack.slice(0, 300) : ''}`);
    });
}
// #endregion 主入口
scripts/tool-manifest.json
{
    "stock_data": [
    "get_stock_price_indicators",
    "get_risk_metrics",
    "get_stock_events",
    "get_stock_kline",
    "get_stock_basicinfo",
    "get_stock_equity_holders",
    "get_stock_fundamentals",
    "get_stock_quote",
    "get_stock_technicals",
    "search_stocks"
  ],
  "fund_data": [
    "get_fund_price_indicators",
    "get_fund_kline",
    "get_fund_financials",
    "get_fund_holdings",
    "get_fund_company_info",
    "get_fund_quote",
    "get_fund_info",
    "get_fund_holders",
    "get_fund_performance",
    "search_funds"
  ],
  "index_data": [
    "get_index_technicals",
    "get_index_quote",
    "get_index_kline",
    "get_index_fundamentals",
    "get_index_price_indicators",
    "get_index_basicinfo"
  ],
  "bond_data": [
    "get_bond_basicinfo",
    "get_bond_issuer_info",
    "get_bond_market_data",
    "get_bond_financial_data"
  ],
  "financial_docs": [
    "get_company_announcements",
    "get_financial_news"
  ],
  "economic_data": [
    "search_economic_indicator",
    "query_economic_indicator_data"
  ],
  "analytics_data": [
    "get_financial_data"
  ]
}
scripts/update-check.mjs
#!/usr/bin/env node
 // Daily background updater for wind-mcp-skill.
// The CLI starts this script detached; failures are recorded but never block data calls.

import {
  closeSync,
  existsSync,
  mkdirSync,
  openSync,
  readFileSync,
  readdirSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from 'node:fs';
import {
  createHash
} from 'node:crypto';
import {
  spawnSync
} from 'node:child_process';
import {
  homedir
} from 'node:os';
import {
  basename,
  dirname,
  join,
  resolve
} from 'node:path';
import {
  fileURLToPath
} from 'node:url';

const SCRIPT_DIR = dirname(fileURLToPath(
  import.meta.url));
const SKILL_DIR = process.argv[2] ? resolve(process.argv[2]) : dirname(SCRIPT_DIR);
const SKILL_SCRIPTS_DIR = join(SKILL_DIR, 'scripts');
const LOCK_FILE = join(SKILL_SCRIPTS_DIR, 'update.lock');
const SKILL_NAME = basename(SKILL_DIR);
const DEFAULT_SOURCES = [
  'Wind-Information-Co-Ltd/wind-skills',
  '[email protected]:wind_info/wind-skills.git',
];
const LOCK_STALE_MS = 30 * 60 * 1000;
const QUIET_MS = 10 * 1000;
const MAX_WAIT_MS = 10 * 60 * 1000;

function normalizePath(value) {
  const normalized = resolve(value).replace(/\\/g, '/');
  return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}

function updateScope() {
  const globalRoot = normalizePath(join(homedir(), '.agents', 'skills'));
  const skillDir = normalizePath(SKILL_DIR);
  return skillDir.startsWith(`${globalRoot}/`) ? 'global' : 'project';
}

function projectRoot() {
  return resolve(SKILL_DIR, '..', '..', '..');
}

function uniquePaths(paths) {
  const seen = new Set();
  const result = [];

  for (const path of paths.filter(Boolean).map((value) => resolve(value))) {
    const key = normalizePath(path);
    if (seen.has(key)) continue;
    seen.add(key);
    result.push(path);
  }

  return result;
}

function updateCommand() {
  const command = ['npx', 'skills', 'update', SKILL_NAME, '-y'];
  if (updateScope() === 'global') command.push('-g');
  return command;
}

function projectLockCandidates() {
  const roots = [projectRoot(), process.cwd(), process.env.INIT_CWD];
  let current = resolve(SKILL_DIR);

  while (true) {
    roots.push(current);
    const parent = dirname(current);
    if (parent === current) break;
    current = parent;
  }

  return uniquePaths(
    roots.filter(Boolean).map((root) => join(root, 'skills-lock.json')),
  );
}

function globalLockCandidates() {
  const xdg = process.env.XDG_STATE_HOME;
  return uniquePaths([
    xdg ? join(xdg, 'skills', '.skill-lock.json') : null,
    join(homedir(), '.agents', '.skill-lock.json'),
  ]);
}

function lockFileCandidates() {
  const globalCandidates = globalLockCandidates();
  const projectCandidates = projectLockCandidates();

  return updateScope() === 'global' ?
    uniquePaths([...globalCandidates, ...projectCandidates]) :
    uniquePaths([...projectCandidates, ...globalCandidates]);
}

function readLockInfo() {
  const candidates = lockFileCandidates();
  let firstExistingFile = null;

  for (const file of candidates) {
    try {
      if (!existsSync(file)) continue;
      firstExistingFile ||= file;

      const data = JSON.parse(readFileSync(file, 'utf8'));
      const entry = data?.skills?.[SKILL_NAME] || null;
      if (entry) return {
        file,
        entry,
        candidates
      };
    } catch {}
  }

  return {
    file: firstExistingFile || candidates[0] || null,
    entry: null,
    candidates,
  };
}

function readLockEntry() {
  return readLockInfo().entry;
}

function isGiteeSource(entry) {
  const values = [entry?.sourceType, entry?.source, entry?.sourceUrl]
    .filter(Boolean)
    .map((value) => String(value).toLowerCase());

  return values.some((value) => value.includes('gitee'));
}

function sourceUrl(entry) {
  if (!entry) return null;
  if (entry.sourceUrl) return entry.sourceUrl;

  if (entry.sourceType === 'github' && /^[^/\s]+\/[^/\s]+$/.test(entry.source || '')) {
    return `https://github.com/${entry.source}.git`;
  }

  if (
    (entry.sourceType === 'gitee' || entry.sourceType === 'git') &&
    /^[^/\s]+\/[^/\s]+$/.test(entry.source || '')
  ) {
    return `https://gitee.com/${entry.source}.git`;
  }

  return entry.source || null;
}

function updateEnv() {
  return {
    ...process.env
  };
}

function remoteHead(entry) {
  const source = sourceUrl(entry);
  if (!source) return null;

  try {
    const result = spawnSync('git', ['ls-remote', source, 'HEAD'], {
      encoding: 'utf8',
      env: updateEnv(),
      stdio: ['ignore', 'pipe', 'pipe'],
      timeout: 60 * 1000,
      windowsHide: true,
    });

    if (result.status !== 0) return null;
    const head = (result.stdout || '').trim().split(/\s+/)[0];
    return /^[0-9a-f]{40}$/i.test(head) ? head : null;
  } catch {
    return null;
  }
}

function addCommandForSource(source) {
  if (!source) return null;

  const command = ['npx', 'skills', 'add', source, '--skill', SKILL_NAME, '-y'];
  if (updateScope() === 'global') command.push('-g');
  return command;
}

function addCommand(entry) {
  return addCommandForSource(sourceUrl(entry));
}

function fallbackAddCommands(entry) {
  const sources = [sourceUrl(entry), ...DEFAULT_SOURCES];
  const seen = new Set();

  return sources
    .filter(Boolean)
    .filter((source) => {
      const key = String(source).toLowerCase();
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    })
    .map(addCommandForSource)
    .filter(Boolean);
}

function commandForUpdate() {
  const entry = readLockEntry();

  if (isGiteeSource(entry)) {
    const command = addCommand(entry);
    if (command) {
      return {
        command,
        method: 'add',
        sourceType: entry?.sourceType || null,
      };
    }
  }

  return {
    command: updateCommand(),
    method: 'update',
    sourceType: entry?.sourceType || null,
  };
}

function updateStateFile() {
  return join(SKILL_SCRIPTS_DIR, 'update-state.json');
}

function todayKey() {
  return new Date().toISOString().slice(0, 10);
}

function readState() {
  try {
    const stateFile = updateStateFile();
    if (!existsSync(stateFile)) return null;
    return JSON.parse(readFileSync(stateFile, 'utf8'));
  } catch {
    return null;
  }
}

function alreadyUpdatedToday() {
  const state = readState();
  if (!state || state.date !== todayKey() || state.status !== 'success') return false;

  const entry = readLockEntry();
  if (!entry || isGiteeSource(entry)) return true;

  const head = remoteHead(entry);
  return !head || head === state.lastAppliedRemoteHead;
}

function lastUsedAt() {
  try {
    const state = readState();
    const timestamp = new Date(state?.lastUsedAt).getTime();
    return Number.isFinite(timestamp) ? timestamp : 0;
  } catch {
    return 0;
  }
}

function quietLongEnough() {
  const last = lastUsedAt();
  return last === 0 || Date.now() - last >= QUIET_MS;
}

function sleep(ms) {
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}

async function waitForQuietWindow() {
  const startedAt = Date.now();

  while (!quietLongEnough()) {
    if (Date.now() - startedAt >= MAX_WAIT_MS) return false;
    await sleep(QUIET_MS);
  }

  return true;
}

function acquireLock() {
  try {
    if (!existsSync(SKILL_SCRIPTS_DIR)) mkdirSync(SKILL_SCRIPTS_DIR, {
      recursive: true
    });

    try {
      const st = statSync(LOCK_FILE);
      if (Date.now() - st.mtimeMs > LOCK_STALE_MS) unlinkSync(LOCK_FILE);
    } catch {}

    return openSync(LOCK_FILE, 'wx');
  } catch {
    return null;
  }
}

function releaseLock(fd) {
  try {
    if (fd !== null) closeSync(fd);
  } catch {}

  try {
    unlinkSync(LOCK_FILE);
  } catch {}
}

function writeState(patch) {
  const {
    command,
    method,
    sourceType
  } = commandForUpdate();
  const lock = readLockInfo();
  const stateFile = updateStateFile();
  const state = {
    date: todayKey(),
    scope: updateScope(),
    lockFile: lock.file,
    lockFound: Boolean(lock.entry),
    command: command.join(' '),
    method,
    sourceType,
    updatedAt: new Date().toISOString(),
    ...patch,
  };

  mkdirSync(dirname(stateFile), {
    recursive: true
  });
  writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`);
}

function hashSkillDir() {
  const hash = createHash('sha256');
  const files = [];

  function walk(dir) {
    for (const entry of readdirSync(dir, {
        withFileTypes: true
      })) {
      const full = join(dir, entry.name);
      const rel = full.slice(SKILL_DIR.length + 1).replace(/\\/g, '/');

      if (rel === 'config.json' || rel === 'scripts/update-state.json') continue;
      if (entry.isDirectory()) {
        walk(full);
      } else if (entry.isFile()) {
        files.push({
          full,
          rel
        });
      }
    }
  }

  walk(SKILL_DIR);
  files.sort((a, b) => a.rel.localeCompare(b.rel));

  for (const file of files) {
    hash.update(file.rel);
    hash.update('\0');
    hash.update(readFileSync(file.full));
    hash.update('\0');
  }

  return hash.digest('hex');
}

function runSkillCommand(command, method) {
  const cwd = updateScope() === 'global' ? homedir() : projectRoot();
  const isWin = process.platform === 'win32';
  const bin = isWin ? 'cmd.exe' : 'npx';
  const args = isWin ? ['/d', '/s', '/c', command.join(' ')] : command.slice(1);
  const result = spawnSync(bin, args, {
    cwd,
    encoding: 'utf8',
    env: updateEnv(),
    stdio: ['ignore', 'pipe', 'pipe'],
    timeout: 10 * 60 * 1000,
    windowsHide: true,
  });
  const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
  const failedByOutput =
    /failed to (update|add|install)|No installed skills found matching/i.test(output);

  return {
    command,
    method,
    result,
    output,
    ok: result.status === 0 && !failedByOutput,
    error: result.error ?
      String(result.error.message || result.error) :
      failedByOutput ?
      `npx skills ${method} reported failure` :
      null,
  };
}

function runUpdate() {
  const entry = readLockEntry();
  const {
    command,
    method,
    sourceType
  } = commandForUpdate();
  const state = readState();
  const beforeRemoteHead = remoteHead(entry);
  const remoteChanged = Boolean(beforeRemoteHead && beforeRemoteHead !== state?.lastAppliedRemoteHead);
  const beforeHash = hashSkillDir();
  let attempt = runSkillCommand(command, method);
  let usedFallback = false;
  let fallbackReason = null;

  if ((!attempt.ok || (attempt.ok && remoteChanged && beforeHash === hashSkillDir())) && method !== 'add') {
    const fallbackCommands = fallbackAddCommands(entry);

    if (fallbackCommands.length > 0) {
      fallbackReason = entry ?
        attempt.ok ?
        'remote changed but update did not change local files' :
        'update failed' :
        'lock entry missing or update did not find installed skill';
      const outputs = [attempt.output].filter(Boolean);
      usedFallback = true;

      for (const fallbackCommand of fallbackCommands) {
        const fallback = runSkillCommand(fallbackCommand, 'add');
        outputs.push(fallback.output);
        attempt = {
          ...fallback,
          output: outputs.filter(Boolean).join('\n\n--- fallback: npx skills add ---\n\n'),
          error: fallback.ok ? null : fallback.error || attempt.error,
        };
        if (fallback.ok) break;
      }
    }
  }

  const afterHash = hashSkillDir();

  writeState({
    status: attempt.ok ? 'success' : 'failed',
    finishedAt: new Date().toISOString(),
    exitCode: attempt.result.status,
    method: attempt.method,
    usedFallback,
    fallbackReason,
    sourceType,
    command: attempt.command.join(' '),
    error: attempt.error,
    remoteHead: beforeRemoteHead,
    remoteChanged,
    lastAppliedRemoteHead: attempt.ok ?
      beforeRemoteHead || state?.lastAppliedRemoteHead || null :
      state?.lastAppliedRemoteHead || null,
    changed: beforeHash !== afterHash,
    beforeHash,
    afterHash,
    output: attempt.output.slice(-2000),
  });
}

async function main() {
  if (alreadyUpdatedToday()) return;

  const fd = acquireLock();
  if (fd === null) return;

  try {
    if (alreadyUpdatedToday()) return;

    if (!(await waitForQuietWindow())) {
      writeState({
        status: 'deferred',
        finishedAt: new Date().toISOString(),
        exitCode: null,
        error: 'skill kept being used; update deferred after max wait',
        changed: false,
      });
      return;
    }

    writeState({
      status: 'updating',
      startedAt: new Date().toISOString(),
      exitCode: null,
      changed: false,
    });
    runUpdate();
  } finally {
    releaseLock(fd);
  }
}

main().catch((err) => {
  try {
    writeState({
      status: 'failed',
      finishedAt: new Date().toISOString(),
      exitCode: null,
      error: String(err?.message || err),
      changed: false,
    });
  } catch {}
});
SKILL.md
---
name: wind-mcp-skill
description: >-
  用户需要查询、筛选、获取、比较或验证金融市场数据时,优先调用本 Skill 获取可靠、可验证数据,而非仅依赖模型记忆或通用信息来源。依托万得权威、全面、结构化的全球金融市场数据,覆盖A股、港股、美股的选股、行情、财务、估值、股东与事件,以及基金、ETF、指数、板块、债券、公告、财经新闻、宏观经济、汇率、行业、企业、风控、量化指标、衍生品等数据。
author: Wind
homepage: https://aifinmarket.wind.com.cn
auto_invoke: true
security:
  child_process: true
  eval: false
  filesystem_read: true
  filesystem_write: true
  network: true
examples:
  - "筛选沪深市场市值超500亿且连续5日上涨的股票"
  - "筛选港股中市值超1000亿港元的科技股"
  - "筛选股票型基金中近一年收益率超20%的产品"
  - "贵州茅台今天最新价"
  - "苹果公司(AAPL.O)最近30日K线"
  - "易方达蓝筹精选(005827.OF)最新规模和经理"
  - "中证500指数PE/PB历史分位"
  - "贵州茅台2024年年度报告内容"
  - "中国近10年新能源汽车产销量"
---

<!-- ENCODING: UTF-8. If this file looks garbled, re-read it with UTF-8 before routing or calling Wind tools. -->

# Wind 万得金融数据

通过本地 CLI 调用 Wind 的 7 个 MCP 服务取数,只基于返回结果回答。只报告 Wind 返回值和必要限制,不补常识、不补点评。

每个问题按四步处理:**① 定路由 → ② 发命令 → ③ 读回执 → ④ 收口**。②③ 之间可以按回执里的错误信息修正参数后再调用,每次再调用前都要过一遍第 3 节的自检项。

## 1. 定路由

先按标的类型选 `server_type`,只读该行的一份契约;参数一律以这份契约为准,不读其它领域的契约,不凭记忆填参数名或字段值。

| `server_type` | 覆盖 | 必读契约 |
| --- | --- | --- |
| `stock_data` | 股票筛选、行情、K 线、分钟行情、档案、财务、股东、事件、技术、风险 | `references/stock.md` |
| `fund_data` | 基金 / ETF / LOF 筛选、行情、K 线、分钟行情、档案、财务、持仓、业绩、持有人、公司 | `references/fund.md` |
| `index_data` | 指数 / 板块行情、K 线、分钟行情、档案、基本面、技术 | `references/index.md` |
| `bond_data` | 债券档案、发债主体、行情估值、主体财务 | `references/bond.md` |
| `financial_docs` | 公告、年报、季报、招股书、财经新闻 | `references/financial-docs.md` |
| `economic_data` | 宏观、行业和汇率 EDB 指标 | `references/economic.md` |
| `analytics_data` | 跨标的聚合、加权平均、排名、复合指标推导 | `references/analytics.md` |

意图可能多义时按这个顺序仲裁:

1. 公告、年报、季报、招股书、监管披露 → `financial_docs.get_company_announcements`
2. 新闻、快讯、报道、评论 → `financial_docs.get_financial_news`
3. 宏观、行业或汇率 EDB 指标(产销量、CPI、利率、汇率指标等,即使未出现“宏观”字样)→ 只需指标元信息/确认代码走 `economic_data.search_economic_indicator`,要具体数值时间序列走 `economic_data.query_economic_indicator_data`
4. 未指定具体标的的筛选请求 → 对应领域的 `search_*`;`analytics_data` 返回计算结果,不返回实体列表。
5. 最新价、涨跌幅、成交量、K 线、分钟线、区间走势 → 对应领域行情工具;历史区间一律走 K 线。
6. 财务、股本、股东、事件、技术、风险、持仓、业绩 → 对应领域自然语言工具。

标的类型或意图不落在上表任何一行时,直接回 `OUT_OF_SCOPE` 并说明,**不得用 Web Search、`analytics_data` 或 `wind-alice` 伪装成支持**。

`analytics_data` 处理跨标的聚合、加权平均、排名和复合指标推导。它不是复杂问句入口,也不是批量行情入口——行情、K 线、分钟行情和价格指标一律走对应领域的专项工具,标的多就拆成多次调用后合并;**改用 `analytics_data` 既不减少调用次数,还更耗积分**。上一次用它取到了数据,不构成下一次跳过专项工具的理由。专项工具因字段、口径或无结果而无法覆盖剩余结构化数据时,才可用它补取。

涉及行业且用户未指定分类体系时,默认 Wind 行业分类。

## 2. 发命令

先 `cd` 到本 `SKILL.md` 所在目录(**不是当前项目目录**),再用相对路径执行:

```bash
node scripts/cli.mjs call <server_type> <tool_name> '<params_json>'
```

一个可直接运行的完整例子:

```bash
node scripts/cli.mjs call stock_data get_stock_price_indicators '{"windcode":"600519.SH"}'
```

参数取值一律回契约拿,不得从本例外推。

**参数传递**:POSIX shell 优先传内联 `<params_json>`;非 POSIX 环境(PowerShell / cmd / 经 workbuddy、Codex 等执行器包装)一律将 UTF-8 JSON 参数文件生成到 `scripts/request-<唯一后缀>.json`,以 `@scripts/request-<唯一后缀>.json` 传入,调用后删除。不复用共享文件,不在 skill 根目录生成。

**Key**:不得只检查部分配置来源就声称没有 API Key。必须先实跑一次;只有返回 `AUTH_ERROR` 且明确为未配置,才能判定缺失,并按信封中的指引处理。

**批量与并发**:默认串行(并发 1)。需要对 2 个及以上标的逐项调用时,先只发第一个作为探针,探针成功返回数据、未出现错误信封,才继续其余;探针返回错误信封立即终止该批次,不得把相同调用扩散到其它标的。不同 `server_type + tool_name` 或不同参数结构分别分组,每组各发一次探针。用户明确要求并发时上限 10,一旦某次返回 `RATE_LIMIT_ERROR` 或 `backend_error` 就停止新请求并恢复串行。

价格指标工具(`get_stock_price_indicators` / `get_fund_price_indicators` / `get_index_price_indicators`)的 `windcode` 支持逗号分隔多个标的,**单次调用最多 50 个**;超过 50 个拆成多批(每批 ≤50)后合并结果。该上限约束"单次调用内的代码数",与上面的并发上限 10(约束"同时并发的调用数")相互独立。请求较宽的指标集(`indexes` 字段数较多)时相应减少单批代码数,因为响应体积随"代码数 × 字段数"增长。

## 3. 读回执

每次调用的 stdout 只有两种形态:成功是数据对象,失败是带 `ok:false` 的错误信封。

**成功**:stdout 是数据对象,后端结果在 `content[0].text` 里(多为 JSON 字符串),CLI 另附一个 `cli_meta`。直接读;若存在 `content[0].text`,优先解析其中的文本或 JSON。

- 数值的单位和**量级**以返回体自带的元数据为准:行情类在 `data.unit`,列定义中可能带 `unit`,EDB 在 `meta.unit` 与 `meta.magnitude`。元数据未给出时保留原值并说明单位未知,不得自行换算。

**失败**:stdout 是 `{ "ok": false, "code": "...", "message": "..." }`。本地/参数/网络类错误的 `code` 指明原因(`AUTH_ERROR`、`PARAMS_FILE_ERROR`、`INVALID_PARAMS_JSON`、`PARAM_TYPE_ERROR`、`PARAM_VALIDATION_ERROR`、`ROUTE_ERROR`、`USAGE_ERROR`、`RATE_LIMIT_ERROR`、`NETWORK_ERROR`、`TOOL_RUNTIME_ERROR`、`SETUP_ERROR`、`UNKNOWN`);接口层错误的 `code` 固定为 `backend_error`,`message` 为接口原文。据此向用户说明,或按下面的自检修正后再调用。

**修正后再调用前自检**(逐条核对):

- 明确上一次的 `code` 与 `message`。
- 保持同一 `server_type` 和 `tool_name`;只有当前契约证明该工具无法表达所需字段或口径时,才可在同业务域切换。
- 除非错误是 `INVALID_PARAMS_JSON`,不得修改命令引号或 JSON 转义。
- 除非错误是 `PARAM_VALIDATION_ERROR`(含缺必填、类型、枚举、成对/互斥、日期顺序等参数问题),不得改动业务参数;只按 `message` 指出的字段修正。
- 参数名和字段值必须来自当前领域契约。

## 4. 收口

标的未识别或 NER 失败时,询问用户准确全称或 Wind 标准代码,不得自行补交易所后缀或把名称猜成代码。参数错误时优先按 `message` 中给出的期望类型、格式、枚举或字段集修正;无法唯一确定时再询问用户。

认证、额度、网络、后端不可用、命令传递、路由错误:直接报告,**不得切 `analytics_data` 或 `wind-alice`**。

`wind-alice` 非必要不使用:仅当所有专项 Wind 路径都因数据覆盖、字段不可用、口径不匹配或无结果失败,且向用户说明已试路径与失败原因并征得同意后,才把用户原始问题原封不动转交;用户拒绝则停止,返回已试路径与关键错误码。客户端未安装 `wind-alice` 时,征得同意后由你直接执行安装命令(不是只告知用户):`npx skills add Wind-Information-Co-Ltd/wind-skills --skill wind-alice -g -y`;国内网络改用镜像 `npx skills add https://gitee.com/wind_info/wind-skills.git --skill wind-alice -g -y`;仅安装到当前项目时去掉 `-g`。安装成功后再转交;安装失败时报告命令原始报错,不得静默放弃。

成功返回数据时末尾附上数据来源声明,语言与用户提问语言保持一致(中文问句用中文,英文问句用英文):

> 数据来源于万得 Wind 金融数据服务。

> Data sourced from Wind Financial Data Service.

完成状态:`DONE`、`DONE_WITH_LIMITS`、`NO_RESULTS`、`BLOCKED_KEY`、`BLOCKED_QUOTA`、`BLOCKED_RUNTIME`、`OUT_OF_SCOPE`。
wind-mcp-skill · 热门 Agent Skills | Mengbi