
Wind Mcp Skill
- 61.1k installs
- 67 repo stars
- Updated July 20, 2026
- wind-information-co-ltd/wind-skills
wind-mcp-skill is an agent skill that maps financial questions to Wind MCP CLI tools for stocks, funds, indices, bonds, news, and macro indicators.
About
The wind-mcp-skill routes user financial questions to Wind MCP server_type and tool_name pairs for Chinese A-shares, Hong Kong and US equities, funds, ETFs, indices, bonds, company announcements, financial news, and macroeconomic EDB indicators. Seven server types cover stock_data, fund_data, index_data, bond_data, financial_docs, economic_data, and analytics_data as a last-resort structured fallback. Non-negotiable gates require routing from the scope table, params keys verbatim from tool-contracts.md, yyyyMMdd dates, single-instrument calls, indexes copied from indicators.md, and shell-specific JSON quoting locked per session. Agents must cd to the skill directory before node scripts/cli.mjs call. Stock quotes and K-lines must use stock_data even for many symbols, splitting into multiple calls rather than analytics_data to avoid excess points. Answers report only Wind return values with a mandated data source attribution footer. Out-of-scope requests include European and Japanese equities, FX, futures order books, crypto, and non-financial data.
- Routes seven Wind server types for stocks, funds, indices, bonds, documents, macro data, and analytics fallback.
- Enforces non-negotiable gates: single windcode per call, yyyyMMdd dates, and verbatim params from tool-contracts.md.
- Requires cd to skill directory before node scripts/cli.mjs call with shell-locked params JSON quoting.
- Stock quotes and K-lines must use stock_data routes, splitting multi-symbol requests instead of analytics_data.
- Answers only Wind-returned data with mandated 数据来源于万得 Wind 金融数据服务 attribution footer.
Wind Mcp Skill by the numbers
- 61,086 all-time installs (skills.sh)
- +13,414 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #1 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
wind-mcp-skill capabilities & compatibility
- Capabilities
- stock, fund, and index screening and quotes · k line and minute bar retrieval · financial documents and news search · macro edb indicator queries · strict cli routing and error recovery
- Use cases
- data analysis · research
What wind-mcp-skill says it does
将用户问题映射到 Wind 支持的 `server_type + tool_name`
单次工具调用只允许一个标的
数据来源于万得 Wind 金融数据服务。
npx skills add https://github.com/wind-information-co-ltd/wind-skills --skill wind-mcp-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 61.1k |
|---|---|
| repo stars | ★ 67 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 20, 2026 |
| Repository | wind-information-co-ltd/wind-skills ↗ |
How do agents query Wind financial data with correct server routing, parameter keys, and single-instrument CLI calls?
Route financial data queries to Wind MCP CLI tools for A-share, HK, US stocks, funds, indices, bonds, announcements, news, and macro indicators.
Who is it for?
Agents answering China-market and global equity, fund, index, bond, announcement, news, or macro data questions via Wind.
Skip if: Skip for European or Japanese equities, FX, futures order books, crypto, or non-financial queries.
When should I use this skill?
Screening A-shares, fetching K-lines, fund NAV, index PE/PB, bond valuations, announcements, news, or macro EDB series.
What you get
Wind MCP CLI results with validated routing, verbatim parameters, and source-attributed financial answers.
- Wind-sourced financial data answers
- Attributed market snapshots
By the numbers
- Covers seven server_type domains with analytics_data as structured fallback only.
- Single tool call allows only one windcode per invocation.
Files
<!-- ENCODING: UTF-8. If this file looks garbled, re-read it with UTF-8 before routing or calling Wind CLI. -->
Wind 万得金融数据
你是 Wind MCP 调用路由器。将用户问题映射到 Wind 支持的 server_type + tool_name,按 references/tool-contracts.md 构造参数,调用 CLI,并只基于 Wind 返回结果回答。
不可协商门禁
按顺序执行;任一门禁不满足,只修当前门禁,不得跳到后续步骤。
1. 路由:server_type + tool_name 必须来自上方范围表(7 个 server_type 对应的覆盖范围和常见意图);路由校验由 CLI 完成,选错会返回 ROUTE_ERROR。股票行情、K 线、分钟行情、价格指标等请求只要能映射到 stock_data 行情工具,就必须使用 stock_data;大量标的也要拆成多次专项行情调用后合并,不得为了省调用次数改用 analytics_data.get_financial_data 兜底,以免造成不必要的积分消耗。 2. 参数:params key 必须逐字来自 references/tool-contracts.md。 3. 参数值:日期必须是 yyyyMMdd;自然语言入参按工具合约原样传递,不得为空或全空白;宏观 EDB 新工具的 question 允许自然语言短语。 4. 单标的:单次工具调用只允许一个标的;行情类 windcode 必须是单个字符串,禁止数组、逗号拼接或多代码字符串。多标的对比拆成多次调用后合并。 5. 指标:使用 indexes 时,只选择用户明确请求的指标;值必须逐字来自 references/indicators.md,不得补充用户未提到的指标。 6. 命令格式:首次 CLI 调用前先确认 shell / 执行器类型,按下方「params JSON 写法」表锁定 <params_json> 引号。锁定后除非命中 INVALID_PARAMS_JSON,不得修改 shell 引号或 JSON 转义。 7. 失败:非 0 退出先读 stdout 的 error.code 和 error.agent_action;agent_action 包含完整的域分类和具体操作步骤,直接执行即可。错误只能在对应错误域内修复,不得跨域改动。 8. 回答:只报告 Wind 返回值和必要限制,不补常识、不补点评。
Key 判定规则:不得手动检查部分配置来源后声称没有 API Key。必须直接执行 CLI;CLI 会一次性按“用户全局配置 > Skill 本地配置 > WIND_API_KEY 环境变量”检查全部来源。只有 CLI 返回 AUTH_ERROR 且 detail 明确为“未配置”,才能判定 Key 缺失。
范围
| server_type | 覆盖范围 | 常见意图 |
|---|---|---|
stock_data | A股 / 港股 / 美股 | 股票筛选、行情、K 线、分钟行情、档案、财务、股东、事件、技术、风险 |
fund_data | 基金 / ETF / LOF | 基金筛选、行情、K 线、分钟行情、档案、财务、持仓、业绩、持有人、管理公司 |
index_data | 指数 / 板块 | 行情、K 线、分钟行情、档案、基本面、技术 |
bond_data | 债券 | 档案、发债主体、行情估值、主体财务 |
financial_docs | 公告 / 财经新闻 | 年报、季报、公告、招股书、新闻、快讯、报道 |
economic_data | 宏观 / 行业指标 | GDP、CPI、PPI、PMI、社融、利率、失业率、进出口等 EDB 指标 |
analytics_data | 通用结构化取数 | 仅在专项路由无法覆盖结构化取数时兜底 |
不用于欧股、日股、其它未覆盖市场、汇率、期货盘口、加密货币或非金融数据。不得用 Web Search、 analytics_data 或 wind-alice 伪装支持超范围请求。
工作流
开始前:若本文件或引用文件出现乱码,先用 UTF-8 重新读取再继续。然后按下面顺序处理每个用户问题。
1. 分析意图:判断用户要的是选股筛选、文档 / 新闻、宏观指标、行情或时序、专项业务数据、通用结构化取数,还是超范围请求。 2. 判断标的类型:识别 A股、港股、美股、基金 / ETF / LOF、指数 / 板块、债券、文档主体或宏观指标。简称或别名可能歧义时先问用户。 3. 选择 `server_type`:用标的类型匹配上方范围表。A股、港股、美股都用 stock_data。 4. 选择 `tool_name`:按意图在 references/tool-contracts.md 中找到对应工具;路由校验由 CLI 完成,选错会返回 ROUTE_ERROR。 5. 构造参数:只读取所选工具在 references/tool-contracts.md 中的段落,逐字使用其中的参数 key,并守住门禁 3 / 4 / 5。自然语言字段对应关系:
- 选股筛选、领域 NL 工具和
analytics_data使用question financial_docs使用queryeconomic_data.get_economic_data使用metricIdsStr传递自然语言指标查询,可选填写beginDate/endDate/freq/magnitude/currency
涉及行业筛选、行业分类或行业对比,且用户未指定分类体系时,默认使用 Wind 行业分类。
6. 调用前检测:逐条核对不可协商门禁;凡入参需要填写指标 / 字段名(如 indexes)时,只读 references/indicators.md 的相关类别,逐项核对、逐字复制——每次调用都核对一遍,不复用记忆,不添加用户未请求的指标。 7. 调用 CLI:调用前必须先 cd 到 skill 目录,即本 SKILL.md 所在目录、不是当前项目目录,再用相对路径执行 node scripts/cli.mjs call <server_type> <tool_name> <params_json>。不 cd 会找不到脚本。<params_json> 的引号 / 转义以已锁定命令格式为准,见下方「params JSON 写法」表。 8. 处理结果:成功(exit code 0)则解析 stdout 并回答——call 成功时 stdout 是 MCP result,若存在 content[0].text,优先解析其中的文本或 JSON。失败(exit code 1)则执行 error.agent_action。每次重试前按下方「重试前审计」核对。
重试前审计
每次重试前必须内部核对:
- 上一次
error.code是什么。 - 本次计划修改是否属于该错误码允许的错误域。
- 是否保持同一
server_type和tool_name;只有tool-contracts.md证明当前工具无法表达字段 / 口径时才可在同业务域切换。 - 除非上一次错误是
INVALID_PARAMS_JSON,否则不得修改命令引号 / JSON 转义。 - 除非上一次错误是
PARAM_VALIDATION_ERROR、NO_RESULTS,或agent_action明确要求缩小范围 / 减少字段,否则不得修改业务参数。 - params key 不得来自
tool-contracts.md之外;indexes不得来自indicators.md之外。
路由顺序
意图可能多义时,优先选择最具体的专项路径:
1. 公告、年报、季报、招股书、监管披露 -> financial_docs.get_company_announcements 2. 新闻、媒体、快讯、报道、评论、消息 -> financial_docs.get_financial_news 3. 宏观或行业 EDB 指标 -> economic_data.get_economic_data 4. A股 / 港股 / 美股选股、筛选股票、找出符合条件股票,且用户未指定具体股票 -> stock_data.search_stocks 5. 基金筛选、筛选基金、找出符合条件基金,且用户未指定具体基金 -> fund_data.search_funds 6. 最新价、涨跌幅、成交量、K 线、分钟线、"最近 N 天 / 区间 / 走势" -> 对应市场的行情工具(走势 / 区间历史一律走 K 线,不得用 analytics_data 代替)。用户查询大量股票行情数据时,A股 / 港股 / 美股一律优先拆分为多次 stock_data 行情工具调用后合并结果,不得为了省调用次数改用 analytics_data.get_financial_data,因为该兜底工具可能消耗更多积分。 7. 财务、股本、股东、事件、技术、风险、持仓、业绩、主体财务 -> 对应领域 NL 工具 8. 专项路由无法覆盖的结构化取数 -> analytics_data.get_financial_data
analytics_data 不是复杂问句入口,也不是批量行情入口。股票行情、K 线、分钟行情、价格指标等请求只要能映射到 stock_data 行情工具,就必须继续使用 stock_data;即使标的很多,也应拆分调用并合并结果,避免用 analytics_data.get_financial_data 兜底造成不必要的积分消耗。只有专项工具无法覆盖剩余结构化数据,或允许的专项路径因字段 / 口径 / 无结果失败后,才可用它补取并合并结果。单次工具调用只查一个标的;多标的对比拆成多次调用后合并。
不得将某次 analytics_data.get_financial_data 兜底成功视为 stock_data 行情工具不可用;后续新的股票行情、K 线、分钟行情、价格指标请求仍必须重新按路由规则优先使用 stock_data。
params JSON 写法
调用前先确认命令最终交给哪种 shell / 执行器,按下表写 <params_json> 的引号;同一会话锁定一种写法,命中 INVALID_PARAMS_JSON 前不改写。
| 执行路径 | <params_json> 写法 |
|---|---|
| Bash / zsh / sh / Git Bash / WSL | '{"windcode":"600519.SH"}' |
| Windows PowerShell | '{\"windcode\":\"600519.SH\"}' |
| cmd.exe | "{\"windcode\":\"600519.SH\"}" |
| agent 工具 / JSON-RPC / 任务运行器等包一层的执行器 | 先按 Bash 式写;命中 INVALID_PARAMS_JSON 时按其 agent_action 用 argv 探针校准 |
判断标准只有一个:第三参数必须能被 Node 当 process.argv[2] 读取并 JSON.parse 解析。不要凭屏幕显示判断转义对错。
资源导航
| 读取或运行 | 何时 | 权威于 |
|---|---|---|
references/tool-contracts.md | MUST:选定工具后读对应段落 | 工具字段、参数、场景、示例 |
references/indicators.md | MUST:入参需填指标 / 字段名时(如 indexes),每次核对 | Wind 指标 / 字段名词典 |
references/fallback-alice.md | MAY:判定可切 wind-alice 后 | wind-alice 最终兜底流程 |
引用优先级:CLI stdout 的 error.code / error.agent_action 是当前失败的直接指令,包含完整的操作步骤; 业务参数以 references/tool-contracts.md 和 references/indicators.md 为准;命令传递写法见「params JSON 写法」表。 不同 reference 看似冲突时,停止重试并说明文档不一致,不得自行选择更方便的解释。
失败与回答
失败处理遵循门禁 7:直接按 error.agent_action 执行。agent_action 已包含具体操作步骤(如 Key 获取流程、参数修复方向、重试策略等),不需要查阅其它错误文档。 只有所有允许的 Wind MCP 路径(含允许的 analytics_data 兜底)都因数据覆盖、字段不可用、查询口径不匹配或无可用结果失败后,才可推荐 wind-alice;触发时读取 references/fallback-alice.md 并先问用户。
不得因以下错误使用 analytics 兜底或 wind-alice:认证、额度、网络、后端不可用、命令传递、路由错误。
回答遵循门禁 8:只返回 Wind 实际数据。若数据时效、缺失字段、报告期滞后、无结果或口径限制会 影响解释,必须说明。成功返回数据时末尾附上:
数据来源于万得 Wind 金融数据服务。
完成状态
DONE:Wind 工具成功返回结果,并已标注数据来源。DONE_WITH_LIMITS:成功返回部分结果,但存在字段缺失、报告期滞后、口径限制或部分无数据。NO_RESULTS:Wind 返回无结果,且已说明尝试路径和可调整方向。BLOCKED_KEY:Key 缺失或无效。BLOCKED_QUOTA:额度、余额或限流阻塞继续。BLOCKED_RUNTIME:网络、后端、CLI 或命令传递错误阻塞继续。OUT_OF_SCOPE:用户请求不属于 Wind MCP 支持范围。
wind-mcp-skill
访问万得 Wind 金融数据 · A股 / 港股 / 美股 / 基金 / 指数 / 债券 / 公告 / 新闻 / 宏观经济
---
这是什么
通过 MCP 协议访问万得 Wind 金融数据库,给 AI Agent 提供:
- A股 / 港股 / 美股股票筛选 + 行情(最新价 / K 线 / 分钟)+ 财务基本面(档案 / 财报 / 股本 / 事件 / 技术指标 / 风险)
- ETF / 公募基金筛选 + 行情 + 全维数据(档案 / 财务 / 持仓 / 业绩 / 持有人 / 管理公司)
- 指数 / 板块行情 + 档案 / 基本面(成份股加权 PE / PB / PS)/ 技术指标
- 债券基本档案 / 发债主体 / 行情估值(久期 / 凸性 / 利差)/ 主体财务
- 上市公司公告 + 财经新闻 RAG
- 宏观经济 / 行业经济指标(EDB)
- 自然语言通用查询入口(仅在专项能力无法覆盖时兜底,覆盖更广泛的 Wind 数据库)
不包含:欧股 / 日股 / 其它非中概非美股、汇率 / 期货盘口、加密货币、非金融数据。
---
安装
# 全局(推荐 — 跨项目 + 跨 AI agent 共享)
# GitHub
npx skills add Wind-Information-Co-Ltd/wind-skills --skill wind-mcp-skill -g -y
# Gitee 镜像(国内)
npx skills add https://gitee.com/wind_info/wind-skills.git --skill wind-mcp-skill -g -y想限制在当前项目内用,把命令的-g去掉即可。-g会按 skills 工具支持的客户端进行全局安装 / 链接(如 Claude Code / Cursor / OpenClaw / Hermes 等)。
---
API Key
需要 WIND_API_KEY(登录 Wind金融AI市场 获取)。
装好后向 AI 提一个 wind 数据问题,AI 会按 stdout JSON envelope 里的 error.agent_action / error.hint 引导完成 Key 配置——无需手动管路径。也可以在本 skill 目录下运行:
node scripts/cli.mjs open-portal如果 CLI 报 KEY_MISSING,按 stdout JSON 里的 error.agent_action / error.hint 配置即可;程序按用户全局配置、当前 Skill 本地配置、环境变量 WIND_API_KEY 的顺序读取。
---
使用注意
analytics_data只是兜底入口;公告 / 新闻、宏观、行情、财务基本面等明确意图应优先走对应server_type。references/tool-manifest.json是 CLI 校验server_type + tool_name的权威清单;错误组合会在真正调用后端前被本地拒绝。- Windows PowerShell 5.x 中 JSON 转义容易导致
INVALID_PARAMS_JSON。如果遇到该错误,请优先看 SKILL.md 里的 Shell 转义说明。 - K 线工具必须同时传
begin_date/end_date;分钟级行情工具字段名是begin/end。 - 行情类
indexes建议从 references/indicators.md 复制表内字段名。 - 单次工具调用只支持单标的;多标的对比需要拆成多次调用。
- Codex 沙箱中调用 Wind 后端联网时,需要使用
require_escalated。
---
升级
# 装到全局(默认推荐)
npx skills update wind-mcp-skill -g -y
# 装到当前项目(不带 -g)
npx skills update wind-mcp-skill -ycall 命令调用时会触发后台自动更新检查。每天首次使用时异步执行一次 npx skills update wind-mcp-skill -y;如果当前 skill 位于全局 ~/.agents/skills 下,则自动追加 -g。执行结果写入当前 skill 根目录的 update-state.json;当天后续调用不会再次执行,且不会阻塞正常取数。
---
目录结构
wind-mcp-skill/
├── SKILL.md # AI 加载的核心守则(数据范围 / 使用方法 / 工具表 / 注意事项 / 使用技巧 / 出错怎么办)
├── references/
│ ├── indicators.md # 行情字段 indexes 中文清单(按类别分组)
│ └── tool-manifest.json # CLI 前置校验的 server_type / tool_name 权威清单
├── scripts/
│ ├── cli.mjs # MCP 调用主入口
│ └── update-check.mjs # 每日一次后台自动更新
└── README.md详细的工具列表 / 入参 schema / 字段说明见 SKILL.md。
{
"schema_version": 4,
"description": "wind-mcp-skill CLI 错误码字典。每条 code 对应一段 agent_action(诊断 + 行动一体)。CLI 运行时读取本文件;本文件也是开发者参考与监控/集成的唯一码表。",
"envelope_contract": {
"success": "成功路径不返回 envelope,直接 stdout 输出纯数据。call 命令透传 result.content[0].text(JSON 字符串则 parse 后输出);其它命令直接输出结构化对象;help 输出 USAGE 纯文本。exit code 0。",
"failure": "失败路径返回 envelope: { ok: false, error: { code, agent_action } }。exit code 1。agent 用 exit code 判断成功/失败;失败时直接执行 agent_action 即可。"
},
"codes": {
"TEMPORARILY_UNAVAILABLE": "后端偶发不可用。原样重试一次即可,可能是临时波动。重试仍失败则告知用户稍后再试,不得修改参数、工具或路由。",
"INVALID_PARAM_NAME": "后端参数名错误:请求中包含不存在的字段或缺少必填字段。1) 读取 references/tool-contracts.md 当前工具段落,逐项核对字段名是否与文档完全一致;2) 检查是否遗漏必填字段;3) 只修 detail 指出的字段名问题,保持同一 server_type+tool_name 和其他参数不变。禁止改 shell 引号、发明字段名或切换工具。",
"INVALID_PARAM_VALUE": "后端参数值错误:字段名正确但值不合法。1) 读取 references/tool-contracts.md 当前工具段落,核对 detail 指出字段的值域要求(日期格式 yyyyMMdd、枚举值、windcode 格式等);2) 含 indexes 时读取 references/indicators.md 相关类别,逐字核对;3) 只修 detail 指出的值问题,保持同一 server_type+tool_name 和其他参数不变。禁止改字段名、shell 引号或切换工具。",
"USAGE_ERROR": "命令用法错误。确认命令格式为 cli.mjs call <server_type> <tool_name> '<params_json>',修正后重试。只修 CLI 形态,保持 server/tool/params 语义不变。",
"INVALID_PARAMS_JSON": "命令传递错误:第三参数 JSON 解析失败。1) 按 SKILL.md「params JSON 写法」表核对当前 shell / 执行器的引号写法;2) 用同一执行路径运行 argv 探针:node -e \"JSON.parse(process.argv[1]); console.log(process.argv[1])\" '<params_json>';3) 探针通过后,最终 CLI 调用的第三参数必须逐字符复用探针通过的 params_json。只修 JSON 字符串/引号/执行器转义;禁止改字段、日期、indexes、question 或工具。",
"ROUTE_ERROR": "路由错误:server_type 或 tool_name 不合法。detail 中列出了可用选项。1) 根据 detail 中的可用列表,结合 SKILL.md 范围表重选合法的 server_type;2) 读取 references/tool-contracts.md 对应 server_type 的工具列表,重选合法的 tool_name;3) 保持用户原始数据意图,不要直接 fallback 到 analytics_data。",
"PARAM_VALIDATION_ERROR": "业务参数错误:本地基础校验或后端参数校验未通过。1) 读取 references/tool-contracts.md 当前工具的段落,逐项核对字段名、必填项、日期格式(yyyyMMdd)、单标的和枚举值;2) 含 indexes 时读取 references/indicators.md 相关类别,每个 index 必须逐字存在;3) 只修 detail 指出的错误项,保持同一 server_type+tool_name 和业务参数语义。禁止改 shell 引号、添加用户未请求的指标、发明字段名或直接上 analytics。",
"AUTH_ERROR": "认证错误。不得手动检查部分来源后判定缺 Key;CLI 已按“用户全局配置 > Skill 本地配置 > WIND_API_KEY 环境变量”完整检查。detail 中有具体原因,分两种情况处理:1) Key 未配置(detail 含'未配置'):先问用户选择——由 agent 执行 node scripts/cli.mjs open-portal 打开开发者中心,还是用户自行获取 Key;拿到 Key 后问用户选 global 还是 skill scope,执行 node scripts/cli.mjs setup-key <KEY> --scope <global|skill>,再原样重试失败的调用;2) Key 无效或过期(detail 含 HTTP 401):让用户到开发者中心重新生成 Key,用 setup-key 配置后原样重试。不得换工具/路由绕过。",
"QUOTA_ERROR": "额度/限流错误。detail 中有具体原因,分三种情况处理:1) 日额度用完(detail 含'超限'或'daily'):告知用户等次日 0 点额度刷新后重试,或换有额度的备用 Key;2) QPS 限流(detail 含'频繁'或'qps'或 HTTP 429):等 3-5 秒后原样重试同一请求;3) 余额不足(detail 含'余额'或'充值'):告知用户到开发者中心充值,或换有余额的 Key。不得换工具/路由绕过。保持同一 server_type+tool_name 和参数。",
"NETWORK_ERROR": "网络/后端错误。detail 中有具体原因和 HTTP 状态码,分两种情况处理:1) 网络连接失败(detail 含 DNS/超时/代理/ECONNREFUSED):检查本机网络和联网权限,修复后原样重试同一请求;2) 后端 HTTP 5xx(detail 含 HTTP 500/502/503/504):先按 references/tool-contracts.md 当前工具段落核对 params key/value(必填、日期、枚举、单标的、indexes),入参有错只修该错重试一次;入参无误则稍后原样重试。不得改业务参数或路由。保持同一 server_type+tool_name。",
"TOOL_RUNTIME_ERROR": "后端工具运行错误。detail 中有后端返回的原文。按以下顺序处理:1) 看 detail 是否明确指出问题——请求过大、字段不支持、数据未覆盖等;2) 若能明确定位原因,只修对应项重试一次(如缩小时间范围、减少 indexes、换字段);3) 若 detail 是泛化提示(如'服务暂不可用'或'工具执行失败'),按 references/tool-contracts.md 当前工具段落核对 params key/value 后重试一次;4) 无法定位则告知用户后端原文并停止。不得切换工具、改 shell 引号或扩展指标。",
"NO_RESULTS": "数据结果为空:后端执行成功但未匹配到数据。保持同一 server_type、tool_name 和用户意图,只可调整关键词、时间范围或粒度中与原问题直接相关的一项,最多重试一次。仍无结果时按路由顺序尝试 analytics_data 兜底或 wind-alice 最终兜底(先问用户)。禁止编造值或用常识补全。",
"SETUP_ERROR": "本地配置/环境错误,发生在 setup-key 或 open-portal 过程中。detail 中有具体原因和路径,分三种情况处理:1) scope 不合法(detail 含'scope'):问用户选择 global(全局共享)或 skill(仅当前 skill),带 --scope <global|skill> 重试 setup-key;2) 配置写入失败(detail 含'写入失败'或路径):detail 中有目标路径,检查路径权限,或建议用户换另一个 scope 重试;3) 浏览器无法打开(detail 含'浏览器'或'URL'):把 detail 中的 URL 告知用户,让用户手动在浏览器中打开。不要改数据查询。",
"UNKNOWN": "未知错误。detail 中有后端原文或异常信息。尝试从 detail 识别属于哪个已知错误域:命令传递→检查 JSON 格式;业务参数→检查字段名和值;认证→检查 Key;网络→检查连接。能定位则只在该域修复并重试一次;无法定位则告知用户 detail 原文并停止。"
}
}
wind-alice 最终兜底
何时读:按 SKILL.md「失败与回答」判定可切 wind-alice 后 | 权威于:wind-alice 兜底流程 | 不覆盖:触发条件(见 SKILL.md「失败与回答」与 references/error-codes.json)本流程是所有 Wind MCP 路径的最终兜底。触发条件,以及哪些错误不构成触发条件 (Key、权限、额度、网络、后端不可用、JSON 转义、未知工具等须先修复根因,不走兜底), 统一见 SKILL.md「失败与回答」与 references/error-codes.json 各码的 agent_action;确认满足后再执行本流程。
触发前检查
1. 确认已经尝试可用的专项 Wind 路径。 2. 确认 analytics_data 兜底在当前问题上被允许且已失败,或当前问题明确不适合该兜底。 3. 确认失败摘要可说明数据覆盖、字段或口径问题。 4. 确认客户端环境是否能加载 wind-alice。仓库里存在 skills/wind-alice 源码,不等于客户端已经安装。
已安装 wind-alice
用 AskUserQuestion 让用户决定是否切换,不得自动切换。
选项至少包含:
| 选项 | 含义 |
|---|---|
| 改用 wind-alice 继续 | 让 Alice Agent 用自然语言链路继续尝试 |
| 停止兜底 | 返回已尝试路径、错误码和后端原文摘要 |
用户同意后,将用户原始问题原封不动作为 wind-alice 的 prompt。默认不传 Alice 子 skill;只有用户明确点名 Alice 子 skill,例如“公司一页纸”“事实核验” 或“上市公司调研问题清单”时才传。
未安装 wind-alice
用 AskUserQuestion 告知用户需要先安装,给出继续安装或停止兜底的选择。 若用户选择安装,提供当前项目可用命令:
# GitHub
npx skills add Wind-Information-Co-Ltd/wind-skills --skill wind-alice -g -y
# Gitee 镜像(国内)
npx skills add https://gitee.com/wind_info/wind-skills.git --skill wind-alice -g -y若用户只想安装到当前项目,去掉命令中的 -g。
用户拒绝
尊重选择,停止继续 fallback。简要返回:
- 已尝试的 Wind 路径
- 关键错误码
- 后端原文摘要或无结果摘要
Wind 指标 / 字段名词典(indexes 等指标类入参取值)
何时读:入参需要填写 Wind 指标 / 字段名时(如indexes),每次都核对 | 权威于:Wind 指标 / 字段名词典 | 不覆盖:自然语言字段question/query/metricIdsStr
本表是 Wind 指标 / 字段名的唯一权威词典。凡入参需要填写指标 / 字段名的调用都以本表为准—— 最常见是 *_price_indicators 工具的 indexes 参数(股票 / 港美股 / 基金 / 指数共用同一份词典), 但不限于此。每次构造这类参数都回本表逐字核对;表内没有的字段不要传,不要凭记忆或猜拼写。
怎么用
1. 先按用户问题定位类别(目录里挑一两个相关 H2) 2. 在该类别表里挑精确字段名(中文,严格按本表) 3. 多个字段用英文逗号 , 拼成一个字符串传给 indexes 4. 字段表里没有的概念 → 告诉用户"该指标不在 Wind 行情字段范围内",不要猜名字
# A股
node scripts/cli.mjs call stock_data get_stock_price_indicators \
'{"windcode":"600519.SH","indexes":"中文简称,最新成交价,涨跌幅,成交量,换手率,市盈率(TTM)"}'
# 美股
node scripts/cli.mjs call stock_data get_stock_price_indicators \
'{"windcode":"AAPL.O","indexes":"中文简称,最新成交价,涨跌幅,52周最高,52周最低"}'
# 指数
node scripts/cli.mjs call index_data get_index_price_indicators \
'{"windcode":"000300.SH","indexes":"最新成交价,涨跌幅,成交量,成交额"}'品种适用 hint(字段词典是 enum 全集 ≠ 一定有数据)
| 类别 | 适用品种 |
|---|---|
| 元数据 / 基础行情 / 盘口五档 / 多周期涨跌幅 / 流动性 / 技术指标 / 盘中异动 / 资金流向 / 盘前盘后 | 股票 + 基金 + 指数 通用 |
| 估值 / 市值 / 股息率 | 股票 + 指数为主(基金的 PE/PB 极少有意义) |
| 净值 / IOPV / 申购状态 / 基金规模 / 七日年化 | 基金专属(传给股票/指数返空) |
| 期货价格 / 期权希腊字母 / 权证价格 / 可转债 / 债券价格-收益率多形态 | 对应品种专属(传给股票/基金/指数返空) |
| 成分股贡献点数 / 上涨家数下跌家数 | 指数专属 |
⚠️ 字段陷阱
1. 必须照抄字面:市净率(LF)、涨跌、涨跌幅、5分钟涨跌幅、市盈率(TTM)、52周最高、基于Wind算法的量比,括号 / 全角字符 / 阿拉伯数字 一字不差。 2. 括号区分含义:市盈率(TTM) / 市盈率(LYR) / 市盈率(预测) 是三个不同字段;总市值1 / 总市值2 同理(2 是含限售股的口径)。 3. 极易混淆的同名变种:涨跌 (单位:元) ≠ 涨跌幅 (单位:%) ≠ 涨跌BP (单位:bp,债券专用) ≠ 5分钟涨跌幅。 4. schema 列了不代表后端实现了 / 不代表当前品种有数据。遇到字段返空或报错,不要在快照工具里反复试拼写,直接切 NL 类工具兜底:
- 多周期涨跌幅 / 历史走势 / 资金流时间序列 →
get_stock_technicals/get_index_technicals({question}自然语言) - 财务 / 估值衍生指标 →
get_stock_fundamentals/get_fund_financials/get_index_fundamentals - 跨域综合 →
analytics_data.get_financial_data
铁律:从本表里复制粘贴字段名,不要凭印象敲;字段不识别就立刻切 NL,不要反复试。
---
目录
- 元数据 / 基础行情
- 盘口五档
- 成交统计
- 期货专属
- 基础元信息
- 估值
- 流动性 / 振幅
- 多周期涨跌幅
- 市值 / 52 周
- 股息 / 涨跌停
- 基金净值与规模
- 债券价格-收益率
- 可转债
- 期权
- 资金流向
- 技术指标
- 盘中异动
- 盘前盘后
- 指数专属
- 历史多周期 / 涨跌幅扩展
- 期权统计
- 债券 YTC/P 与 YCU 形态
- 其它
---
元数据 / 基础行情
| 字段 | 单位 / 说明 |
|---|---|
最新交易日 | 最新有数据的交易日 |
交易时间 | 该数据快照对应的具体时刻 |
最新成交价 | 元 |
前收盘价 | 元 |
今日开盘价 | 元 |
今日最高价 | 元 |
今日最低价 | 元 |
成交量 | 股 / 份 |
现额 | 当前刻成交额 |
现量 | 当前刻成交量 |
盘口五档
| 字段 |
|---|
买一价 买二价 买三价 买四价 买五价 |
卖一价 卖二价 卖三价 卖四价 卖五价 |
买一量 买二量 买三量 买四量 买五量 |
卖一量 卖二量 卖三量 卖四量 卖五量 |
报买方 报卖方 |
成交统计
| 字段 | 说明 |
|---|---|
外盘 | 主动买入成交量 |
内盘 | 主动卖出成交量 |
成交额 | 元 |
成交笔数 | |
日成交量 | 日累计 |
日成交额 | 日累计 |
近1分钟成交额 近3分钟成交额 近5分钟成交额 近7日平均成交额 | |
K线实体涨跌幅 K线实体涨跌额 | |
前一交易日涨跌幅 |
期货专属
| 字段 |
|---|
结算价 前结算价 持仓量 前持仓 日增仓 日增仓(%) 交割月份 |
基础元信息
| 字段 |
|---|
中文简称 发行价 上市日期 流通份额 总股本 |
最新均价 涨跌 涨跌幅 5分钟涨跌幅 交易状态 |
估值
| 字段 | 说明 |
|---|---|
市净率 | 普通 PB |
市净率(LF) | 最新财报口径 |
市盈率(TTM) | TTM 口径 |
市盈率(LYR) | 上年报口径 |
市盈率(预测) | 预测口径 |
流动性 / 振幅
| 字段 |
|---|
换手率 量比 委比 振幅 基于Wind算法的量比 |
多周期涨跌幅
| 字段 |
|---|
5日涨跌幅 10日涨跌幅 20日涨跌幅 60日涨跌幅 120日涨跌幅 250日涨跌幅 年初至今涨跌幅 |
市值 / 52 周
| 字段 | 说明 |
|---|---|
总市值1 | 流通口径 |
流通市值 | |
总市值2 | 含限售股口径 |
52周最高 52周最低 |
股息 / 涨跌停
| 字段 |
|---|
股息率 涨停价 跌停价 回收价 上涨家数 下跌家数 平盘家数 正股换手率 |
基金净值与规模
| 字段 | 说明 |
|---|---|
权证价格 | 权证类专用 |
最新净值 上期净值 累计净值 | |
最新净值增长率 年初以来净值增长率 成立以来净值增长率 | |
近一周净值增长率 近一月净值增长率 近一季净值增长率 近半年净值增长率 近一年净值增长率 近两年净值增长率 近三年净值增长率 近五年净值增长率 | |
贴水率 基金最新份额 申购状态 | |
整体溢价率 基金综合评级 基金规模 七日年化收益率 万份基金收益 IOPV |
债券价格-收益率
| 字段 | 说明 |
|---|---|
最新价(全) 最新价(净) 前收(全) 前收(净) | 全价 / 净价 |
开盘价(全) 开盘价(净) 最高价(全) 最高价(净) 最低价(全) 最低价(净) | |
最新YTM 前收(YTM) 最新加权YTM 开盘价(YTM) 最高价(YTM) 最低价(YTM) | 到期收益率 |
加权平均 上日均价 上日加权YTM | |
麦氏久期 修正久期 凸性 | |
最优买YTM 最优卖YTM 买一YTM 卖一YTM | |
涨跌BP 5日涨跌BP 10日涨跌BP 20日涨跌BP 60日涨跌BP 120日涨跌BP 250日涨跌BP | bp |
买YTM-中债(Bp) 中债-卖YTM(Bp) 买YTE-中债(Bp) 中债-卖YTE(Bp) | |
买YTM-中证(Bp) 中证-卖YTM(Bp) 买YTE-中证(Bp) 中证-卖YTE(Bp) | |
买YTM(参考) 卖YTM(参考) 买YTE 卖YTE 最新YTE 买量 卖量 买(净) 卖(净) |
可转债
| 字段 |
|---|
转股价格 转股比例 转股价值 转股溢价率 套利空间 债券余额(万元) 街货量占比 街货量(千份) 购沽成交比 |
纯债溢价率 平价底价溢价率 可转债风格类型(平衡型/偏债型/偏股型) 纯债价值 |
期权
| 字段 | 说明 |
|---|---|
理论价格 成交价隐含波动率 中价隐含波动率 行权价格 | |
内在价值 时间价值 溢价率 价内外程度 杠杆比率 实际杠杆倍数 | |
Delta Gamma Vega Theta Rho Gamma1Pct DeltaCash 1%GammaCash | 希腊字母 |
行权类型 行权比例 到期日 正股价格 正股涨跌幅 剩余天数 期现差 距回收价 | |
时间价值(标的) 期权价值状态 保证金 无风险利率 隐含分红率 基差 集合竞价涨跌幅 | |
成交价Vega 成交价Theta 成交价Rho | |
买一隐含波动率 买二隐含波动率 买三隐含波动率 买四隐含波动率 买五隐含波动率 | |
卖一隐含波动率 卖二隐含波动率 卖三隐含波动率 卖四隐含波动率 卖五隐含波动率 | |
昨IV IV涨跌幅 买卖价差 买卖相对价差 |
资金流向
| 字段 |
|---|
连红天数 当日主力净流入额 当日主力净流入占比 |
近5日主力净流入额 近5日主力净流入占比 近5日主力净流入天数 |
近10日主力净流入额 近10日主力净流入占比 近10日主力净流入天数 |
近20日主力净流入额 近20日主力净流入占比 近20日主力净流入天数 |
近60日主力净流入额 近60日主力净流入占比 近60日主力净流入天数 |
技术指标
| 字段 | 说明 |
|---|---|
指数平滑异同移动平均 | MACD |
DIF快线 | |
随机指标K值 随机指标D值 随机指标J值 | KDJ |
6周期相对强弱指标 12周期相对强弱指标 | RSI |
抛物线转向指标 | SAR |
布林中轨 布林上轨 布林下轨 | BOLL |
5周期移动平均 10周期移动平均 20周期移动平均 60周期移动平均 120周期移动平均 250日均线 | MA |
连续上涨天数 5日乖离率 36日乖离 | |
14周期顺势指标 | CCI |
26周期能量指标 | OBV |
12周期心理线指标 | PSY |
近1分钟涨跌幅 近3分钟涨跌幅 | |
MACD多头金叉信号 MACD空头死叉信号 |
盘中异动
| 字段 |
|---|
火箭发射 高台跳水 涨停封板 跌停封板 涨停开板 跌停开板 涨幅达到3% 跌幅达到3% 创20日新高 创20日新低 |
主力挂单买入 主力挂单卖出 主力撤单买入 主力撤单卖出 |
盘前盘后
| 字段 |
|---|
盘前最新价 盘前涨跌额 盘前涨跌幅 盘前成交额 盘前涨速 |
盘后最新价 盘后涨跌幅 |
涨停时间 跌停时间 涨停封板金额 封单额占成交额比例 |
指数专属
| 字段 |
|---|
成分股贡献点数 近5分钟贡献度 |
历史多周期 / 涨跌幅扩展
| 字段 |
|---|
上市以来涨跌幅 收盘涨跌 收盘涨跌幅(%) |
近3年涨跌幅 近5年涨跌幅 近10年涨跌幅 近20年涨跌幅 近30年涨跌幅 |
期权统计
| 字段 |
|---|
认沽期权成交量 认购期权成交量 认沽期权持仓量 认购期权持仓量 认沽期权成交额 认购期权成交额 |
债券 YTC/P 与 YCU 形态
债券回售 / 赎回收益率系列。
| 字段(YTC/P 系列) |
|---|
前收价YTC/P 开盘价YTC/P 最新价YTC/P 最高价YTC/P 最低价YTC/P 加权价YTC/P |
麦氏久期YTC/P 修正久期YTC/P 凸性YTC/P |
买一行权 买二价YTC/P 买三价YTC/P 买四价YTC/P 买五价YTC/P |
卖一行权 卖二价YTC/P 卖三价YTC/P 卖四价YTC/P 卖五价YTC/P |
| 字段(YCU 系列) |
|---|
最新价YCU 开盘价YCU 最高价YCU 最低价YCU 前收YCU 加权价YCU |
麦氏久期YCU 修正久期YCU 凸性YCU |
买一价YCU 买二价YCU 买三价YCU 买四价YCU 买五价YCU |
卖一价YCU 卖二价YCU 卖三价YCU 卖四价YCU 卖五价YCU |
其它
| 字段 |
|---|
做市商数量 |
{
"schema_version": 1,
"description": "Runtime normalization rules used by scripts/cli.mjs before calling Wind MCP tools.",
"kline_periods": ["1", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"],
"period_aliases": {
"1m": "1",
"1min": "1",
"1分钟": "1",
"5m": "3",
"5min": "3",
"5分钟": "3",
"10m": "4",
"10min": "4",
"10分钟": "4",
"15m": "5",
"15min": "5",
"15分钟": "5",
"30m": "6",
"30min": "6",
"30分钟": "6",
"60m": "7",
"60min": "7",
"1h": "7",
"60分钟": "7",
"120m": "8",
"120min": "8",
"2h": "8",
"120分钟": "8",
"240m": "9",
"240min": "9",
"4h": "9",
"240分钟": "9",
"d": "10",
"day": "10",
"daily": "10",
"日": "10",
"日k": "10",
"日线": "10",
"w": "11",
"week": "11",
"weekly": "11",
"周": "11",
"周k": "11",
"周线": "11",
"m": "12",
"month": "12",
"monthly": "12",
"月": "12",
"月k": "12",
"月线": "12",
"y": "13",
"year": "13",
"yearly": "13",
"年": "13",
"年k": "13",
"年线": "13",
"q": "14",
"quarter": "14",
"quarterly": "14",
"季": "14",
"季k": "14",
"halfyear": "15",
"half-year": "15",
"半年": "15",
"半年k": "15"
},
"indicator_aliases": {
"最新价": "最新成交价",
"最新价格": "最新成交价",
"最新收盘价": "最新成交价",
"收盘价": "最新成交价",
"close": "最新成交价",
"last": "最新成交价",
"price": "最新成交价",
"昨收": "前收盘价",
"昨收价": "前收盘价",
"昨收盘": "前收盘价",
"昨日收盘价": "前收盘价",
"preclose": "前收盘价",
"pre_close": "前收盘价",
"开盘价": "今日开盘价",
"今开": "今日开盘价",
"open": "今日开盘价",
"最高价": "今日最高价",
"high": "今日最高价",
"最低价": "今日最低价",
"low": "今日最低价",
"volume": "成交量",
"vol": "成交量",
"amount": "成交额",
"turnover": "成交额",
"涨幅": "涨跌幅",
"pct_chg": "涨跌幅",
"pctchg": "涨跌幅",
"change_pct": "涨跌幅",
"涨跌额": "涨跌",
"change": "涨跌",
"市盈率": "市盈率(TTM)",
"市盈率ttm": "市盈率(TTM)",
"pe": "市盈率(TTM)",
"pe_ttm": "市盈率(TTM)",
"市净率lf": "市净率(LF)",
"pb": "市净率",
"pb_lf": "市净率(LF)",
"总市值": "总市值2",
"总市值含限售": "总市值2",
"总市值不含限售": "总市值1",
"股息率ttm": "股息率",
"dividend_yield": "股息率",
"近一年收益率": "近一年净值增长率",
"近1年收益率": "近一年净值增长率",
"一年收益率": "近一年净值增长率",
"近一年净值收益率": "近一年净值增长率"
},
"index_code_aliases": {
"HSI": "HSI.HI",
"HSI.HK": "HSI.HI",
"HKHSI": "HSI.HI",
"恒生指数": "HSI.HI",
"HSTECH": "HSTECH.HI",
"HSTECH.HK": "HSTECH.HI",
"恒生科技": "HSTECH.HI",
"DJI": "DJI.GI",
"DJIA": "DJI.GI",
".DJI": "DJI.GI",
"SPX": "SPX.GI",
".SPX": "SPX.GI",
"IXIC": "IXIC.GI",
".IXIC": "IXIC.GI",
"NDX": "NDX.GI",
".NDX": "NDX.GI",
"SOX": "SOX.GI"
},
"legacy_tool_aliases": {
"get_global_stock_price_indicators": ["stock_data", "get_stock_price_indicators"],
"get_global_stock_kline": ["stock_data", "get_stock_kline"],
"get_global_stock_quote": ["stock_data", "get_stock_quote"]
},
"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"
}
}
}
wind-mcp-skill 工具契约
何时读:选定工具后只读对应段落 | 权威于:各工具字段 / 参数 / 示例 | 不覆盖:indexes取值(见references/indicators.md)
按 SKILL.md 完成路由判定后,只读取本文件中与所选工具相关的段落。 调用前仍需校验 references/tool-manifest.json;行情快照 indexes 仍以 references/indicators.md 为唯一权威清单。
目录
1. 工具总表 2. 参数签名 3. 行情工具 4. 领域 NL 工具 5. 文档工具 6. 宏观工具 7. 通用取数兜底 8. 调用示例
工具总表
`tool_name` 必须逐字取自本表;不在表内的名字一律不存在(如 `get_fund_qa`),禁止自造或拼凑。
| server_type | tool_name | 入参 |
|---|---|---|
stock_data | search_stocks | question(+lang / version) |
stock_data | get_stock_price_indicators | windcode + indexes |
stock_data | get_stock_kline | windcode + begin_date + end_date(+period / count / aftime…) |
stock_data | get_stock_quote | windcode(+begin / end) |
stock_data | get_stock_basicinfo / get_stock_fundamentals / get_stock_equity_holders / get_stock_events / get_stock_technicals / get_risk_metrics | question(+lang) |
fund_data | search_funds | question(+lang / version) |
fund_data | get_fund_price_indicators | windcode + indexes |
fund_data | get_fund_kline | windcode + begin_date + end_date |
fund_data | get_fund_quote | windcode(+begin / end) |
fund_data | get_fund_info / get_fund_financials / get_fund_holdings / get_fund_performance / get_fund_holders / get_fund_company_info | question(+lang) |
index_data | get_index_price_indicators | windcode + indexes |
index_data | get_index_kline | windcode + begin_date + end_date |
index_data | get_index_quote | windcode(+begin / end) |
index_data | get_index_basicinfo / get_index_fundamentals / get_index_technicals | question(+lang) |
bond_data | get_bond_basicinfo / get_bond_issuer_info / get_bond_market_data / get_bond_financial_data | question(+lang);无行情快照工具 |
financial_docs | get_company_announcements / get_financial_news | query(+top_k) |
economic_data | get_economic_data | metricIdsStr(+beginDate / endDate / freq…) |
analytics_data | get_financial_data | question(+lang) |
字段级细节(indexes 取值、period / freq 枚举、日期格式)见下方各工具段落与 references/indicators.md。
参数签名
| 工具组 | 入参 | 适用范围 |
|---|---|---|
| 行情类 | {windcode, ...} | 股票 / 港美股 / 基金 / 指数行情工具 |
| 股票筛选 | {question, lang?, version?} | stock_data.search_stocks |
| 基金筛选 | {question, lang?, version?} | fund_data.search_funds |
| 专项 NL | {question, lang?} | stock_data、fund_data、index_data、bond_data NL 工具 |
| 文档 RAG | {query, top_k?} | financial_docs 工具 |
| 宏观 EDB | {metricIdsStr, ...} | economic_data.get_economic_data |
| 通用结构化取数 | {question, lang?} | analytics_data.get_financial_data |
params JSON 的 key 必须逐字复制本文件的字段名。不得把用户口语、其它 API 习惯或通用证券字段名 翻译成别名 key;例如行情类必须使用 windcode,不得写成 code、ticker、symbol、 sec_code 或 stock_code。
单次工具调用只查询一个标的。行情类 windcode 必须是单个字符串,禁止传数组、对象、 逗号分隔多代码、空格分隔多代码或把多个代码拼成一个字符串。用户要求多个指数 / 股票 / 基金对比时,必须拆成多次同工具调用后合并结果;不要把 ["000001.SH","399001.SZ"]、"000001.SH,399001.SZ" 或类似形式传给 windcode。
windcode 优先传用户给出的单个标的名称或代码。Wind 可解析中文名、简称和标准代码,例如:
- A股:
600519.SH、8XXXXX.BJ - 港股:
00700.HK - 美股:
AAPL.O、MSFT.O - 场外基金:
005827.OF - ETF / LOF:
588200.SH、159915.SZ - 指数:
000300.SH、000905.SH、HSI.HI
简称或别名可能映射多个标的时先问用户,不要让后端静默选错。
股票筛选
stock_data.search_stocks(股票筛选)从全市场中筛选符合条件的股票,返回股票代码列表。
触发条件:用户未指定具体股票,而是描述 选股条件,例如市值、涨跌幅、行业、上市板、 连续上涨 / 下跌、技术形态或其它筛选条件。
不要用于:已指定单只股票的行情 / 财务查询;港股、美股、基金、指数、债券筛选;需要返回字段值而非股票代码列表的取数。
| 字段 | 必填 | 说明 |
|---|---|---|
question | 是 | 自然语言选股问句;调用前移除空白字符,不得添加用户未给出的筛选条件 |
lang | "English" / "中文";默认 "中文" | |
version | 后端版本参数;仅当用户或系统明确指定时传入,不得自造 |
示例:{"question":"筛选沪深市场市值超500亿且连续5日上涨的股票"}
港美股筛选
stock_data.search_stocks(港美股筛选)从港股 / 美股中筛选符合条件的股票, 返回港股 / 美股代码列表。
触发条件:用户未指定具体港股 / 美股,而是描述选股条件,例如市场、市值、涨跌幅、行业、 交易所、上市地或其它筛选条件。
不要用于:已指定单只港股 / 美股的行情 / 财务查询;A股、基金、指数、债券筛选; 需要返回字段值而非股票代码列表的取数。
| 字段 | 必填 | 说明 |
|---|---|---|
question | 是 | 自然语言港美股筛选问句;调用前移除空白字符,不得添加用户未给出的筛选条件 |
lang | "English" / "中文";默认 "中文" | |
version | 后端版本参数;仅当用户或系统明确指定时传入,不得自造 |
示例:{"question":"筛选港股中市值超1000亿港元的科技股"}
基金筛选
fund_data.search_funds(基金筛选)从基金产品中筛选符合条件的基金,返回基金代码列表。
触发条件:用户未指定具体基金 / ETF / LOF,而是描述筛选条件,例如基金类型、ETF 主题、 收益率、管理规模、基金公司、投资主题、风险收益特征或其它筛选条件。
不要用于:已指定单只基金的净值 / 规模 / 档案 / 持仓 / 业绩查询;股票、指数、债券筛选; 需要返回字段值而非基金代码列表的取数。
| 字段 | 必填 | 说明 |
|---|---|---|
question | 是 | 自然语言基金筛选问句;调用前移除空白字符,不得添加用户未给出的筛选条件 |
lang | "English" / "中文";默认 "中文" | |
version | 后端版本参数;仅当用户或系统明确指定时传入,不得自造 |
示例:{"question":"筛选股票型基金中近一年收益率超20%的产品"}
行情工具
行情快照指标
用于最新值或其它单时点行情字段。
| server_type | tool_name |
|---|---|
stock_data | get_stock_price_indicators |
fund_data | get_fund_price_indicators |
index_data | get_index_price_indicators |
| 字段 | 必填 | 说明 |
|---|---|---|
windcode | 是 | 单个标的名称或代码;禁止数组、多代码字符串或逗号拼接 |
indexes | 是 | 逗号分隔的精确指标名;每个值必须逐字存在于 references/indicators.md |
indexes 只能覆盖用户明确请求的指标,不得为了“更完整”追加用户未提到的字段。 indexes 禁止猜测、翻译、改写。不得传入未收录的英文缩写、拼音、API 字段名、 用户口语词或自行翻译词;若字段不在 references/indicators.md,改用合适的 NL 工具,或说明该快照字段不可用。 用户口语字段必须映射为表内精确字段后再传入,例如“今开”传 今日开盘价, “昨收”传 前收盘价;找不到表内精确字段时不要传入快照工具。 CLI 会在调用前归一部分高频别名:最新价 / 收盘价 / close -> 最新成交价, 昨收 -> 前收盘价,开盘价 / open -> 今日开盘价,市盈率TTM / pe_ttm -> 市盈率(TTM),总市值 -> 总市值2,近一年收益率 -> 近一年净值增长率。 若用户明确要求“流通口径总市值”,传 总市值1;若明确要求“含限售总市值”,传 总市值2。
以下仅是常用候选,用于定位用户已请求的字段;不得把候选列表当作默认字段集。 传参前仍要逐项核对 references/indicators.md:
- 通用:
中文简称、最新成交价、前收盘价、今日开盘价、今日最高价、
今日最低价、成交量、成交额、涨跌、涨跌幅
- 股票:
换手率、量比、委比、涨停价、跌停价、52周最高、
52周最低、总市值1、流通市值、市盈率(TTM)、市净率、股息率
- 基金:
IOPV、贴水率、基金最新份额、基金规模、最新净值、
累计净值、七日年化收益率
- 指数:
成分股贡献点数、上涨家数、下跌家数、平盘家数
K 线
用于历史行情序列。
| server_type | tool_name |
|---|---|
stock_data | get_stock_kline |
fund_data | get_fund_kline |
index_data | get_index_kline |
| 字段 | 必填 | 默认值 | 说明 |
|---|---|---|---|
windcode | 是 | 单个标的名称或代码;禁止数组、多代码字符串或逗号拼接 | |
begin_date | 是 | yyyyMMdd | |
end_date | 是 | yyyyMMdd | |
count | 正数表示从 begin_date 向后取 N 条;负数表示从 end_date 向前取 N 条 | ||
period | "10" | 1=1分, 3=5分, 4=10分, 5=15分, 6=30分, 7=60分, 8=120分, 9=240分, 10=日K, 11=周K, 12=月K, 13=年K, 14=季K, 15=半年K | |
aftime | "0" | "0"=前复权, "1"=后复权 | |
issusp | "1" | "0"=不含停牌, "1"=含 | |
afdate | 可选复权基准日,yyyyMMdd |
CLI 会把 day / D / daily / 日线 归一为 period:"10",把 week / 周线 归一为 11,把 month / 月线 归一为 12。其它 period 值会在本地校验阶段拦截, 避免后端因 "day" / "D" 等字符串返回 500。
行情工具会做常见标的归一:港股四位码补前导零;裸美股 ticker 如 NVDA 补为 NVDA.O; HSI / HSI.HK 补为 HSI.HI;ETF / LOF 代码会从 stock_data 改路由到 fund_data; 常见指数代码会从 stock_data 改路由到 index_data。
分钟行情
用于日内走势或分钟级行情数据。
| server_type | tool_name |
|---|---|
stock_data | get_stock_quote |
fund_data | get_fund_quote |
index_data | get_index_quote |
| 字段 | 必填 | 默认值 | 说明 |
|---|---|---|---|
windcode | 是 | 单个标的名称或代码;禁止数组、多代码字符串或逗号拼接 | |
begin | LAST | yyyyMMdd 或 LAST | |
end | LAST | yyyyMMdd 或 LAST |
领域 NL 工具
公共参数:
question: string写成标的加业务问题。lang?: "English" | "中文"默认"中文"。- 调用前移除自然语言字段值中的空白字符。
A股 / 港股 / 美股:stock_data
| 工具 | 适用场景 | question 示例 |
|---|---|---|
search_stocks | 全市场 股票筛选,返回代码列表 | "筛选沪深市场市值超500亿且连续5日上涨的股票" |
get_stock_basicinfo | 公司档案、主营、行业、IPO、上市板 | "600519.SH公司基本档案" |
get_stock_fundamentals | 盈利、资产负债、利润、现金流、增长率、银行业专项 | "贵州茅台2024年ROE和净利润增速" |
get_stock_equity_holders | 股本、流通、前十大股东、实控人、限售 | "贵州茅台前十大股东" |
get_stock_events | IPO、增发、配股、并购、ST、分红 | "宁德时代2024年增发和并购事件" |
get_stock_technicals | MACD、KDJ、RSI、BOLL、融资融券、龙虎榜 | "贵州茅台近60日MACD走势" |
get_risk_metrics | Beta、Jensen Alpha、波动率、Sharpe、VaR | "贵州茅台过去1年Beta和波动率" |
基金 / ETF / LOF:fund_data
| 工具 | 适用场景 | question 示例 |
|---|---|---|
search_funds | 全市场基金产品筛选,返回代码列表 | "筛选股票型基金中近一年收益率超20%的产品" |
get_fund_info | 基金档案、费率、经理、风格、业绩基准 | "易方达蓝筹精选(005827.OF)基金档案" |
get_fund_financials | 利润、净值、收入、费用、分红 | "005827.OF2024年净利润和分红" |
get_fund_holdings | 重仓股、资产配置、行业配置 | "005827.OF最新一期重仓股" |
get_fund_performance | 业绩、排名、ETF / 二级交易 | "005827.OF近1年业绩排名" |
get_fund_holders | 持有人结构、申赎、规模变动 | "005827.OF持有人结构" |
get_fund_company_info | 基金管理公司档案、经理团队 | "易方达基金管理公司档案" |
指数 / 板块:index_data
| 工具 | 适用场景 | question 示例 |
|---|---|---|
get_index_basicinfo | 指数档案、发布机构、基日、基点、成份数量 | "沪深300指数档案" |
get_index_fundamentals | PE / PB / PS、营收、利润、现金流、历史分位 | "沪深300PE/PB历史分位" |
get_index_technicals | 多周期涨跌幅、趋向、反趋向、能量、量价、波动 | "中证500的MACD和RSI" |
债券:bond_data
bond_data 没有行情快照工具。债券行情和估值请求走 NL 工具。
| 工具 | 适用场景 | question 示例 |
|---|---|---|
get_bond_basicinfo | 债券档案、发行、规模、价格、票面利率、期限、兑付 | "国债2601基本信息" |
get_bond_issuer_info | 发债主体名称、注册地、行业、股权结构、企业背景 | "国债2601发债主体" |
get_bond_market_data | 报价、估价、溢价、久期、凸性、利差 | "国债2601久期和凸性" |
get_bond_financial_data | 发债主体营收、利润、资产、负债 | "国债2601主体2024年营收" |
文档工具
用户询问文档内容或新闻时,优先使用 financial_docs。
| 工具 | 适用场景 |
|---|---|
get_company_announcements | 官方公告、监管披露、年报、半年报、季报、招股书 |
get_financial_news | 第三方财经新闻、市场报道、政策和政经动态 |
| 字段 | 必填 | 说明 |
|---|---|---|
query | 是 | 调用时不含空白字符的自然语言查询 |
top_k | 返回文档数量 |
宏观工具
宏观和行业 EDB 指标使用 economic_data.get_economic_data。
| 字段 | 必填 | 说明 |
|---|---|---|
metricIdsStr | 是 | 自然语言指标查询,不是指标 ID |
beginDate / endDate | yyyyMMdd | |
freq | 日=1, 工作日=2, 周=3, 月=4, 季=5, 半年=6, 年=7, 年度=8 | |
magnitude | 个, 千, 万, 百万, 千万, 亿, 十亿, 百亿, 千亿, 万亿 | |
currency | USD, CNY, EUR, JPY, AUD, GBP, CHF, CAD, SGD, HKD, MYR, BYR |
通用取数兜底
只有专项路由无法覆盖的结构化取数任务,才使用 analytics_data.get_financial_data。
| 字段 | 必填 | 说明 |
|---|---|---|
question | 是 | 简洁的自然语言取数问题 |
lang | CNS=中文默认,ENS=英文 |
规则:
1. 首次调用必须将用户原始问题去除所有空格后传入,不得改写、概括、翻译或增加用户未给出的筛选条件。 2. 只有首次调用失败、返回空数据或明显不匹配请求后,才可改写或拆分 question。 3. 一个 analytics 问题只聚焦一个取数动作;复杂分析先拆成简单取数步骤,再综合结果。 4. 若任务需要先发现范围,再对范围内成员二次取数,必须显式分步执行。无法得到可靠范围或后端没有可用排名口径时,停止并说明限制。
调用示例
以下示例都要求先 cd 到 skill 目录,即本 SKILL.md 所在目录、不是当前项目目录,再用相对路径执行 node scripts/cli.mjs ...。不 cd 会找不到脚本。
node scripts/cli.mjs call stock_data search_stocks '{"question":"筛选沪深市场市值超500亿且连续5日上涨的股票"}'
node scripts/cli.mjs call stock_data search_stocks '{"question":"筛选港股中市值超1000亿港元的科技股"}'
node scripts/cli.mjs call fund_data search_funds '{"question":"筛选股票型基金中近一年收益率超20%的产品"}'
node scripts/cli.mjs call stock_data get_stock_price_indicators '{"windcode":"600519.SH","indexes":"中文简称,最新成交价,涨跌幅,成交量"}' # indexes 逐字抄 indicators.md
node scripts/cli.mjs call stock_data get_stock_kline '{"windcode":"600519.SH","begin_date":"20260401","end_date":"20260430"}' # 日期 yyyyMMdd,不带 -
node scripts/cli.mjs call stock_data get_stock_kline '{"windcode":"00700.HK","begin_date":"20260401","end_date":"20260430"}'
node scripts/cli.mjs call fund_data get_fund_price_indicators '{"windcode":"588200.SH","indexes":"中文简称,最新成交价,IOPV,贴水率"}'
node scripts/cli.mjs call financial_docs get_financial_news '{"query":"美联储利率政策","top_k":5}' # query 无空格
node scripts/cli.mjs call economic_data get_economic_data '{"metricIdsStr":"中国CPI同比","freq":"月","beginDate":"20240101","endDate":"20261231"}' # 宏观用 beginDate/endDate{
"stock_data": [
"search_stocks",
"get_stock_price_indicators",
"get_stock_kline",
"get_stock_quote",
"get_stock_basicinfo",
"get_stock_fundamentals",
"get_stock_equity_holders",
"get_stock_events",
"get_stock_technicals",
"get_risk_metrics"
],
"fund_data": [
"search_funds",
"get_fund_price_indicators",
"get_fund_kline",
"get_fund_quote",
"get_fund_info",
"get_fund_financials",
"get_fund_holdings",
"get_fund_performance",
"get_fund_holders",
"get_fund_company_info"
],
"index_data": [
"get_index_price_indicators",
"get_index_kline",
"get_index_quote",
"get_index_basicinfo",
"get_index_fundamentals",
"get_index_technicals"
],
"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": [
"get_economic_data"
],
"analytics_data": [
"get_financial_data"
]
}
#!/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';
const SKILL_VERSION = '1.9.2';
// 本地 registry: 工具选择可在任何网络调用前失败
const SERVERS = {
stock_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_stock_data/mcp/',
label: 'Wind A股/港股/美股 股票(选股筛选 + 档案/财务/股本/事件/技术/风险 + 行情/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, 'references', 'tool-manifest.json');
const ERROR_CODES_PATH = join(SKILL_DIR, 'references', 'error-codes.json');
const NORMALIZATION_RULES_PATH = join(SKILL_DIR, 'references', 'normalization-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":"20260401","end_date":"20260430"}'`,
`cli.mjs call stock_data get_stock_quote '{"windcode":"AAPL.O"}'`,
`cli.mjs call index_data get_index_kline '{"windcode":"000300.SH","begin_date":"20260401","end_date":"20260430"}'`,
`cli.mjs call financial_docs get_financial_news '{"query":"美联储利率政策","top_k":3}'`,
`cli.mjs call economic_data get_economic_data '{"metricIdsStr":"中国GDP","endDate":"20261231"}'`,
`cli.mjs call analytics_data get_financial_data '{"question":"查询中国A股市场过去一年的平均成交量"}'`,
];
// ───── 自动更新 ─────
// 每天首次使用 skill 时异步执行一次 npx skills update,不阻塞主流程。
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', () => {});
child.unref();
} catch {}
}
export { triggerUpdateCheck };
// section: 工具函数
// call 成功: 完整透传 MCP result, 不抽取; agent 自行 parse content[0].text
function writeRawCallSuccess(result) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
function writePlainSuccess(data) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
}
// 失败 envelope { ok:false, error:{code, agent_action} }; update 信号走 stderr 不进 stdout
function writeErrorEnvelope(code, detail) {
const envelope = {
ok: false,
error: {
code,
agent_action: buildAgentAction(code, detail),
},
};
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
}
function die(code, detail = null, exitCode = 1) {
writeErrorEnvelope(code, detail);
process.exit(exitCode);
}
function exitWithUsage(usage, exitCode = 0) {
die('USAGE_ERROR', `USAGE:\n${usage}`, exitCode);
}
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(/^/, '').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 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}"。`);
}
}
const BASIC_TEXT_KEYS = ['question', 'query', 'metricIdsStr', 'windcode', 'indexes', 'freq', 'magnitude', 'currency'];
const BASIC_NO_WHITESPACE_KEYS = ['query', 'metricIdsStr'];
const BASIC_DATE_KEYS = ['begin_date', 'end_date', 'beginDate', 'endDate', 'date', 'tradeDate'];
const PRICE_INDICATOR_TOOLS = new Set(['get_stock_price_indicators', 'get_fund_price_indicators', 'get_index_price_indicators']);
const KLINE_TOOLS = new Set(['get_stock_kline', 'get_fund_kline', 'get_index_kline']);
const QUOTE_TOOLS = new Set(['get_stock_quote', 'get_fund_quote', 'get_index_quote']);
const EDB_TOOLS = new Set(['get_economic_data']);
const EDB_FREQ_VALUES = new Set(['日', '工作日', '周', '月', '季', '半年', '年', '年度']);
const EDB_MAGNITUDE_VALUES = new Set(['个', '千', '万', '百万', '千万', '亿', '十亿', '百亿', '千亿', '万亿']);
const EDB_CURRENCY_VALUES = new Set(['USD', 'CNY', 'EUR', 'JPY', 'AUD', 'GBP', 'CHF', 'CAD', 'SGD', 'BYR', 'HKD', 'MYR']);
function readNormalizationRules() {
const rules = JSON.parse(readFileSync(NORMALIZATION_RULES_PATH, 'utf8'));
return {
klinePeriods: new Set(rules.kline_periods || []),
periodAliases: new Map(Object.entries(rules.period_aliases || {})),
indicatorAliases: new Map(Object.entries(rules.indicator_aliases || {})),
indexCodeAliases: new Map(Object.entries(rules.index_code_aliases || {})),
legacyToolAliases: new Map(Object.entries(rules.legacy_tool_aliases || {})),
toolByDomain: rules.tool_by_domain || {},
};
}
const NORMALIZATION_RULES = readNormalizationRules();
const KLINE_PERIODS = NORMALIZATION_RULES.klinePeriods;
const PERIOD_ALIASES = NORMALIZATION_RULES.periodAliases;
const INDICATOR_ALIASES = NORMALIZATION_RULES.indicatorAliases;
const INDEX_CODE_ALIASES = NORMALIZATION_RULES.indexCodeAliases;
const LEGACY_TOOL_ALIASES = NORMALIZATION_RULES.legacyToolAliases;
const TOOL_BY_DOMAIN = NORMALIZATION_RULES.toolByDomain;
function isValidBasicDate(value) {
if (!/^\d{8}$/.test(value)) return false;
const y = Number(value.slice(0, 4));
const m = Number(value.slice(4, 6));
const d = Number(value.slice(6, 8));
const dt = new Date(Date.UTC(y, m - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
}
function normalizeIndicatorKey(value) {
return String(value || '').trim().replace(/\s+/g, '').replace(/[(]/g, '(').replace(/[)]/g, ')').toLowerCase();
}
function normalizeIndexes(indexes) {
if (typeof indexes !== 'string') return indexes;
return indexes.split(',').map((item) => INDICATOR_ALIASES.get(normalizeIndicatorKey(item)) || item.trim()).filter(Boolean).join(',');
}
function looksLikeFundCode(code) {
return /^5\d{5}\.SH$/.test(code) || /^1[56]\d{4}\.SZ$/.test(code) || /^\d{6}\.OF$/.test(code);
}
function looksLikeIndexCode(code) {
return /^(\d{6})\.(CSI|WI|MI|HI|GI)$/.test(code) ||
/^(000300|000905|000852|000016|000001)\.SH$/.test(code) ||
/^(399001|399006|399300)\.SZ$/.test(code) ||
/^[A-Z]{2,10}\.(HI|GI)$/.test(code);
}
function normalizeWindcode(windcode) {
if (typeof windcode !== 'string') return windcode;
const raw = windcode.trim();
const alias = INDEX_CODE_ALIASES.get(raw.toUpperCase());
if (alias) return alias;
const upper = raw.toUpperCase();
if (/^\d{4}\.HK$/.test(upper)) return `0${upper}`;
if (looksLikeIndexCode(upper)) return upper;
if (/^\d{6}$/.test(upper)) {
if (/^9\d{5}$/.test(upper)) return `${upper}.BJ`;
if (/^5\d{5}$/.test(upper)) return `${upper}.SH`;
if (/^1[56]\d{4}$/.test(upper)) return `${upper}.SZ`;
if (/^(000300|000905|000852|000016|000001)$/.test(upper)) return `${upper}.SH`;
if (/^399\d{3}$/.test(upper)) return `${upper}.SZ`;
if (/^[036]\d{5}$/.test(upper)) return `${upper}.${upper.startsWith('6') ? 'SH' : 'SZ'}`;
}
if (/^5\d{5}\.SZ$/.test(upper)) return upper.replace(/\.SZ$/, '.SH');
if (/^1[56]\d{4}\.SH$/.test(upper)) return upper.replace(/\.SH$/, '.SZ');
if (/^[03]\d{5}\.SH$/.test(upper)) return upper.replace(/\.SH$/, '.SZ');
if (/^6\d{5}\.SZ$/.test(upper)) return upper.replace(/\.SZ$/, '.SH');
if (/^9\d{5}\.(SH|SZ)$/.test(upper)) return upper.replace(/\.(SH|SZ)$/, '.BJ');
if (/^[A-Z]{1,5}$/.test(upper)) return `${upper}.O`;
return upper;
}
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 inferServerTypeFromWindcode(currentServerType, windcode) {
if (typeof windcode !== 'string') return currentServerType;
if (looksLikeFundCode(windcode)) return 'fund_data';
if (looksLikeIndexCode(windcode)) return 'index_data';
if (/^\d{4,5}\.HK$/.test(windcode) || /^[A-Z]{1,5}\.(O|N|A|HK|SH|SZ|BJ)$/.test(windcode) || /^\d{6}\.(SH|SZ|BJ)$/.test(windcode)) {
return 'stock_data';
}
return currentServerType;
}
function normalizeCall(server_type, toolName, args) {
const legacyTool = LEGACY_TOOL_ALIASES.get(toolName);
if (legacyTool) [server_type, toolName] = legacyTool;
const normalizedArgs = { ...args };
if (typeof normalizedArgs.indexes === 'string') normalizedArgs.indexes = normalizeIndexes(normalizedArgs.indexes);
if (typeof normalizedArgs.windcode === 'string') normalizedArgs.windcode = normalizeWindcode(normalizedArgs.windcode);
if (typeof normalizedArgs.period === 'string') {
const key = normalizedArgs.period.trim().toLowerCase();
normalizedArgs.period = PERIOD_ALIASES.get(key) || normalizedArgs.period.trim();
}
const family = toolFamily(toolName);
if (family && typeof normalizedArgs.windcode === 'string') {
server_type = inferServerTypeFromWindcode(server_type, normalizedArgs.windcode);
toolName = TOOL_BY_DOMAIN[family]?.[server_type] || toolName;
}
return { server_type, toolName, args: normalizedArgs };
}
function validateBasicParams(params) {
const errors = [];
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return ['params 必须是 JSON object'];
}
for (const key of BASIC_TEXT_KEYS) {
if (!(key in params)) continue;
if (typeof params[key] !== 'string') {
errors.push(`字段 '${key}' 必须是字符串`);
} else if (params[key].trim().length === 0) {
errors.push(`字段 '${key}' 不能为空或全空白`);
}
}
for (const key of BASIC_NO_WHITESPACE_KEYS) {
if (typeof params[key] === 'string' && /\s/.test(params[key])) {
errors.push(`字段 '${key}' 不得含空格或其它空白字符`);
}
}
if (typeof params.windcode === 'string' && params.windcode.includes(',')) {
errors.push("字段 'windcode' 只允许单个标的,禁止逗号拼接多代码");
}
for (const key of BASIC_DATE_KEYS) {
if (!(key in params)) continue;
if (typeof params[key] === 'string' && !isValidBasicDate(params[key])) {
errors.push(`字段 '${key}' 日期格式错误,要求 yyyyMMdd`);
}
}
return errors;
}
function validateToolParams(toolName, params) {
const errors = [];
if (KLINE_TOOLS.has(toolName)) {
for (const key of ['windcode', 'begin_date', 'end_date']) {
if (!(key in params)) errors.push(`K 线工具缺少必填字段 '${key}'`);
}
if ('period' in params && !KLINE_PERIODS.has(String(params.period))) {
errors.push(`字段 'period' 只能是 ${Array.from(KLINE_PERIODS).join('/')},日 K 请传 '10'`);
}
for (const key of ['aftime', 'issusp']) {
if (key in params && !new Set(['0', '1']).has(String(params[key]))) {
errors.push(`字段 '${key}' 只能是 '0' 或 '1'`);
}
}
}
if (EDB_TOOLS.has(toolName)) {
const allowedKeys = new Set(['metricIdsStr', 'beginDate', 'endDate', 'freq', 'magnitude', 'currency']);
for (const key of Object.keys(params)) {
if (!allowedKeys.has(key)) errors.push(`宏观 EDB 工具不支持字段 '${key}'`);
}
if (!params.metricIdsStr) errors.push("宏观 EDB 工具缺少必填字段 'metricIdsStr'");
if (params.freq && !EDB_FREQ_VALUES.has(params.freq)) {
errors.push("字段 'freq' 只能是 日/工作日/周/月/季/半年/年/年度");
}
if (params.magnitude && !EDB_MAGNITUDE_VALUES.has(params.magnitude)) {
errors.push("字段 'magnitude' 取值不在宏观 EDB 工具枚举内");
}
if (params.currency && !EDB_CURRENCY_VALUES.has(params.currency)) {
errors.push("字段 'currency' 取值不在宏观 EDB 工具枚举内");
}
if (params.beginDate && params.endDate && params.beginDate > params.endDate) {
errors.push("字段 'beginDate' 不能晚于 'endDate'");
}
}
return errors;
}
// ───── 认证 ─────
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 本地配置 > 环境变量)');
}
// section: 错误码 — message 来自 HTTP / JSON-RPC / 工具内嵌 JSON, 统一映射成稳定 code
const ERROR_PATTERNS = [
['TEMPORARILY_UNAVAILABLE', /temporarily_unavailable/i, '后端偶发不可用。'],
['INVALID_PARAM_VALUE', /invalid_param_value/i, '后端参数值错误。'],
['INVALID_PARAM_NAME', /invalid_param_name/i, '后端参数名错误。'],
['QUOTA_ERROR', /单日请求次数超限|daily.*limit|余额不足|请先充值|insufficient.*balance|请求过于频繁|qps.*limit|too.*frequent/i, '额度/限流错误。等待额度刷新、换备用 Key 或充值后原样重试。'],
['AUTH_ERROR', /密钥无效|key.*invalid|unauthorized|认证失败|auth.*fail/i, '认证/权限错误。按 Key 机制修复后原样重试。'],
['NO_RESULTS', /未获取到数据|"NO_RESULTS"|no\s*results?|not\s*found|empty\s*result/i, '未获取到匹配数据。先在不改变用户意图的前提下调整关键词或参数。'],
['PARAM_VALIDATION_ERROR', /参数验证失败|参数.*(错误|非法|无效)|字段.*(不存在|不识别|不支持|非法)|invalid\s*(param|argument|field)|missing\s*(param|argument|field|required)/i, '后端参数验证失败。先按 SKILL.md 工具表核对字段名、必填项、日期格式和枚举值后重试。'],
['NETWORK_ERROR', /服务.*暂不可用|服务.*不可用|service\s+unavailable|temporarily\s+unavailable/i, '网络/后端错误。先核对参数再稍后重试。'],
['TOOL_RUNTIME_ERROR', /TOOL_ERROR|tool.*error|工具.*(执行|运行).*错误|runtime.*error/i, '后端工具运行错误。保留后端原文,先检查请求是否过大或口径是否受支持;不要直接切换工具绕过。'],
];
function inferErrorCode(msg) {
if (!msg) return 'UNKNOWN';
for (const [code, pat] of ERROR_PATTERNS) {
if (pat.test(msg)) return code;
}
return 'UNKNOWN';
}
// agent_action = 诊断 + 行动 一体的 NL 处方; 唯一总表在 references/error-codes.json
function loadAgentActions() {
const fallback = {
UNKNOWN: '未知错误。不要盲目重试;先查看当前错误详情,能定位本地问题(参数 / 配置 / 网络)则修正后重试一次,无法定位则保留原文告知用户并停止。',
};
try {
const doc = JSON.parse(readFileSync(ERROR_CODES_PATH, 'utf8'));
const codes = doc && typeof doc.codes === 'object' ? doc.codes : null;
if (!codes) return fallback;
return {
...fallback,
...Object.fromEntries(
Object.entries(codes).filter(([, action]) => typeof action === 'string' && action.trim()),
),
};
} catch {
return fallback;
}
}
const AGENT_ACTIONS = loadAgentActions();
// detail 只保留短诊断,避免后端长文本淹没 agent_action。
function buildAgentAction(code, detail) {
const template = AGENT_ACTIONS[code] || AGENT_ACTIONS.UNKNOWN;
if (code === 'USAGE_ERROR') return template;
if (detail && typeof detail === 'string' && detail.trim()) {
const d = detail.trim().slice(0, 500);
return `[${d}] ${template}`;
}
return template;
}
// section: MCP 调用 — 裸 HTTP + JSON-RPC, 响应兼容 SSE / 纯 JSON
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)}`);
}
const HTTP_ERROR_MAP = {
401: ['AUTH_ERROR', 'API Key 无效或过期'],
429: ['QUOTA_ERROR', '请求过于频繁'],
500: ['NETWORK_ERROR', '服务端异常'],
502: ['NETWORK_ERROR', '网关异常'],
503: ['NETWORK_ERROR', '服务暂不可用'],
504: ['NETWORK_ERROR', '网关超时'],
};
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
});
let resp;
try {
resp = await fetch(server.endpoint, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
die('NETWORK_ERROR', `${err.message} (server=${server_type})`);
}
if (!resp.ok) {
const bodyText = await resp.text().catch(() => '');
const code = HTTP_ERROR_MAP[resp.status]?.[0] || 'UNKNOWN';
const detail = `HTTP ${resp.status} ${resp.statusText} (server=${server_type})` + (bodyText ? ` | body: ${bodyText.slice(0, 200)}` : '');
die(code, detail);
}
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 = payload.error.message || JSON.stringify(payload.error);
die(inferErrorCode(msg), `${msg} (server=${server_type})`);
}
if (payload.result?.isError) {
const msg = payload.result.content?.[0]?.text || JSON.stringify(payload.result);
die(inferErrorCode(msg), `${msg} (server=${server_type})`);
}
// 部分工具把业务错误包在 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);
die(inferErrorCode(msg), `${msg} (server=${server_type})`);
}
if (inner.error && (inner.error.code || inner.error.message)) {
const errCode = inner.error.code || '';
const errMsg = inner.error.message || '';
const combined = errCode ? `${errCode}: ${errMsg}` : errMsg;
die(inferErrorCode(combined), `${combined} (server=${server_type})`);
}
}
}
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
});
}
// section: 命令
async function cmdCall(server_type, toolName, paramsJson) {
if (!server_type || !toolName || !paramsJson) {
exitWithUsage(
`用法:call <server_type> <tool_name> '<params_json>'\n` +
`可用 server_type: ${Object.keys(SERVERS).join(' / ')}\n` +
`典型:\n ${CALL_EXAMPLES.join('\n ')}`,
1,
);
}
let args;
try {
args = JSON.parse(paramsJson);
} catch (e) {
die('INVALID_PARAMS_JSON', `params JSON 解析失败:${e.message} | 原文:${paramsJson.slice(0, 200)}`);
}
({ server_type, toolName, args } = normalizeCall(server_type, toolName, args));
validateToolSelection(server_type, toolName);
const validationErrors = validateBasicParams(args);
validationErrors.push(...validateToolParams(toolName, args));
if (validationErrors.length > 0) {
die('PARAM_VALIDATION_ERROR', validationErrors.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 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(),
};
}
// section: 主入口 — IS_MAIN guard 让单元测试 import 不副作用
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>'\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]),
'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);
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) : ''}`);
});
}
#!/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',
'git@gitee.com: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 {}
});
Related skills
How it compares
Wind MCP CLI router with strict contracts, not a general web search for market data.
FAQ
Who is wind-mcp-skill for?
Agents retrieving Wind financial data for Chinese and global equities, funds, indices, bonds, documents, and macro indicators.
When should I use wind-mcp-skill?
When mapping stock screens, price snapshots, K-lines, fundamentals, announcements, or macro series to Wind MCP CLI tools.
Is wind-mcp-skill safe to install?
Review the Security Audits panel on this page before installing in production.