Skill: etherstrings/tonghuashun-ifind (version 0.5.1)

--- name: tonghuashun-ifind-skill description: Use when the user needs Tonghuashun iFinD market, report, factor, screening, calendar, or other financial data, and all data must come from iFinD after successful authentication. metadata: openclaw: requires: bins: ["python3"] --- # tonghuashun-ifind-skill 当 OpenClaw、Hermes、Codex 或其他 Agent 需要给同花顺 iFinD 用户做金融数据查询时，优先使用这个 skill。 这个 skill 的产品定位只有一句话：给已经有同花顺 iFinD 账号的用户，一个能听懂自然语言的查询入口。 ## 给安装 Agent 的最短指令 如果你是负责安装这个 skill 的 Agent，照下面做，不要发明其它路径： ```bash bash scripts/install_skill.sh ``` 安装结果必须是： ```text ~/.openclaw/workspace/skills/tonghuashun-ifind-skill ``` 安装后先按同花顺 iFinD 官方方式完成鉴权：用户登录 iFinD 超级命令客户端或网页版账号详情，复制 `refresh_token`，再让 skill 换取并缓存 `access_token`。 Agent 必须这样引导用户取 token： ```text 请先打开同花顺 iFinD 超级命令客户端，进入账号详情，复制 refresh_token。 如果你用网页端，可以打开： https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails 登录后在账号详情里复制 refresh_token。复制后发给我，我会只用它换取 access_token 并缓存，不会要求你的同花顺用户名或密码。 ``` ```bash python3 tonghuashun-ifind-skill/scripts/ifind_cli.py auth-set-refresh-token \ --refresh-token "$IFIND_REFRESH_TOKEN" ``` 如果用户已经给了双 token，才直接注入： ```bash python3 tonghuashun-ifind-skill/scripts/ifind_cli.py auth-set-tokens \ --access-token "$IFIND_ACCESS_TOKEN" \ --refresh-token "$IFIND_REFRESH_TOKEN" ``` 查询时永远先用自然语言入口： ```bash python3 tonghuashun-ifind-skill/scripts/ifind_cli.py smart-query \ --query "查一下贵州茅台近三年营收和毛利率" ``` ## 核心规则 1. 强制 iFinD 鉴权：查询前必须有可用 `access_token`，或可用 `refresh_token` 能续期。 2. 所有数据只来自同花顺 iFinD API；不要使用腾讯财经、东方财富或其它公开源补数据。 3. 自然语言查询是第一入口：常见问题首选 `smart-query`，由 skill 负责把用户原话路由到 iFinD。 4. `quote-realtime`、`quote-history`、`market-snapshot`、`fundamental-basic` 是明确场景下的稳定命令。 5. 用户给正式中文股票名时，先用 iFinD `/smart_stock_picking` 查询股票代码和简称，再用解析出的代码调用行情/历史等稳定接口；不要维护全量本地股票表。 6. 用户给高频口语简称或昵称时，例如“茅台、宁王、招行、东财、工行、中芯、迈瑞、药明、平安”，允许用内置小型别名纠偏，避免 iFinD 把口语词误识别成无关股票；行情、财务、研报等实际数据仍必须来自 iFinD。 7. 涨停、A 股榜单、个股画像、资金流、公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块和新股等 A 股常见查询，主要通过 `/smart_stock_picking` 走 iFinD；交易日 / 休市日走 `/date_sequence`。 8. 本地规则没看懂的自然语言问题，会默认交给 iFinD `/smart_stock_picking`，不要让 Agent 先手写 endpoint。 9. `api-call` 只用于高级兜底：`smart-query`、iFinD 自然语言透传和 `endpoint-list` / `endpoint-call` 都不够时再用。 10. 官方鉴权主路径是 `refresh_token -> /get_access_token -> access_token`；不要替用户完成浏览器登录。 11. 不要向用户回显 `access_token` 或 `refresh_token`。 12. 没有可用 iFinD token 时不要继续查询；先用上面的固定话术引导用户去 iFinD 超级命令账号详情复制 `refresh_token`，再执行 `auth-set-refresh-token`。 13. iFinD 返回错误、权限不足、账号无对应接口权限、名称歧义或无法识别证券名称时，直接说明 iFinD 调用失败或需要用户补充更精确名称/代码，不要切换到非同花顺数据源。 ## 鉴权顺序 1. 先复用 `~/.openclaw/tonghuashun-ifind-skill/token_state.json` 里的缓存 token。 2. 如果 `access_token` 过期，自动使用 `refresh_token` 调用 `/get_access_token` 续期。 3. 如果没有可用 token，要求用户登录 iFinD 超级命令客户端或网页版账号详情，复制官方 `refresh_token`。 4. 拿到 `refresh_token` 后执行 `auth-set-refresh-token`，skill 会调用 `/get_access_token` 并保存双 token。 5. 只有用户已经明确提供 `access_token` 和 `refresh_token` 时，才执行 `auth-set-tokens`。 6. 只接收用户提供的 token，不替用户完成浏览器登录，不接收 iFinD 用户名密码。 用户问“token 在哪拿”时，直接回答： 1. 打开同花顺 iFinD 超级命令客户端，进入账号详情，复制 `refresh_token`。 2. 或打开网页版账号详情：`https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails`。 3. 登录后找到并复制 `refresh_token`，只把这个 token 提供给 Agent。 4. Agent 执行 `auth-set-refresh-token --refresh-token "$IFIND_REFRESH_TOKEN"`。 5. 不要让用户提供同花顺用户名或密码，不要回显 token。 官方文档入口： - iFinD HTTP 接口使用说明 / 鉴权说明：`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/help-center/deploy.html` - iFinD Python HTTP 示例：`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/example.html` - iFinD 网页版超级命令账号详情：`https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails` ## 命令面 - `auth-set-refresh-token` - `auth-set-tokens` - `smart-query` - `quote-realtime` - `quote-history` - `market-snapshot` - `fundamental-basic` - `endpoint-list` - `endpoint-call` - `api-call` - `basic-data` - `smart-pick` - `report-query` - `date-sequence` ## 调用建议 - 个股最新价、历史走势、大盘快照、基础财务指标、涨停数据、A 股榜单、个股画像、资金流、公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股、交易日、复杂筛选和行业/主题查询，优先用 `smart-query`。 - 用户输入正式中文股票名时，`smart-query` 会用 iFinD 自身能力解析证券代码；用户输入常见口语简称或昵称时，`smart-query` 会先做小型别名纠偏，再把请求发到 iFinD；如果名称仍有歧义，就让用户补充完整简称或 6 位代码。 - A 股常见问法只要不是明确的行情/历史行情/交易日稳定命令，就交给 iFinD；多数问题保留用户原话，少数“有啥/怎么样/啥消息”等口语会改写成 iFinD 更稳定的“最近公告/分红记录/研报”等正式词，不要把“分红/龙虎榜/公告/北向持股”等误路由成实时股价。 - 如果用户请求已经非常明确，也可以直接用稳定命令：`quote-realtime`、`quote-history`、`market-snapshot`、`fundamental-basic`。 - 如果没有可用 iFinD token，先用官方 `refresh_token` 鉴权，不要尝试其它数据源。 - 常见路由没命中时，`smart-query` 会把用户原话交给 iFinD `/smart_stock_picking`；只有 iFinD 也无法处理时，才看 `endpoint-list`。 - 只有在自然语言入口和命名接口目录都不够时，才去读 [references/routing.md](references/routing.md) 和 [references/use-cases.md](references/use-cases.md)，然后决定是否使用 `api-call`。 - payload 保持 iFinD 原始 JSON 对象，不要把 iFinD 查询语义二次改写成别的结构。 - 如果 `smart-query` 返回需要手动查接口，就先读本地路由文档和 use cases；如果文档里仍找不到合适接口，就明确告诉用户当前 skill 未覆盖该 iFinD 能力，不要乱猜 endpoint。 ## 可选大模型路由 默认使用本地确定性路由。需要强化复杂自然语言解析时，可以启用 OpenAI-compatible Chat Completions 路由器： ```bash export IFIND_ROUTE_LLM_ENABLED=1 export IFIND_ROUTE_LLM_API_KEY="$OPENAI_API_KEY" export IFIND_ROUTE_LLM_MODEL="gpt-4o-mini" ``` 可选变量： - `IFIND_ROUTE_LLM_BASE_URL` - `IFIND_ROUTE_LLM_TIMEOUT` - `IFIND_ROUTE_LLM_MIN_CONFIDENCE` 大模型只允许输出 iFinD 路由计划。低置信度、无效返回或模型调用失败时，skill 会回到本地确定性路由。 ## 外部页面 - ClawHub / OpenClaw: `https://clawhub.ai/etherstrings/tonghuashun-ifind` - Hermes Agent GitHub skill 源: `https://github.com/Etherstrings/tonghuashun-ifind-skill/tree/main/tonghuashun-ifind-skill` 详细示例见 [references/usage.md](references/usage.md)，能力边界先看 [references/capability-matrix.md](references/capability-matrix.md)，路由规则见 [references/routing.md](references/routing.md)，常见用户问法示例见 [references/use-cases.md](references/use-cases.md)。如果要查看当前已封装的命名接口，直接运行 `endpoint-list`。

path: agents/openai.yaml
content: interface:
  display_name: "tonghuashun-ifind-skill"
  short_description: "给同花顺 iFinD 用户使用的自然语言查询 skill；清晰引导用户去 iFinD 超级命令账号详情复制 refresh_token，再优先用 smart-query 查询。"
  default_prompt: "当用户需要同花顺 iFinD 可提供的市场、财务、研报、选股或日期数据时，优先使用 $tonghuashun-ifind-skill。安装路径必须是 ~/.openclaw/workspace/skills/tonghuashun-ifind-skill。安装后先引导用户打开 iFinD 超级命令客户端的账号详情，或打开网页版账号详情 https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails，登录后复制 refresh_token；拿到 refresh_token 后执行 auth-set-refresh-token。不要要求同花顺用户名或密码，不要回显 token。查询永远先走 smart-query，让用户用自然语言表达需求；茅台、宁王、招行、东财、工行、中芯、迈瑞、药明、平安等口语简称可以被纠偏后再查 iFinD。本地规则没命中时，smart-query 会把原问题交给 iFinD smart_stock_picking。没有可用 iFinD token 时不要继续查询，也不要切换到腾讯财经、东方财富或其它公开源。只有自然语言入口和 endpoint-list 都不够时才考虑 api-call。"

policy:
  allow_implicit_invocation: true


path: references/capability-matrix.md
content: # 能力矩阵

这份文档专门给 Agent 看。

目的不是讲实现细节，而是明确回答三件事：

1. 这个 skill 当前到底能处理什么问题
2. 哪些能力需要 iFinD 鉴权
3. 哪些能力当前不要假装能做

## 使用规则

- 所有已支持能力都要求 iFinD 鉴权。
- 不接入公开免费源；没有可用 token、refresh 失败、账号无权限或 iFinD API 返回错误时，直接把 iFinD 失败状态交还给用户。
- 自然语言问题先走 `smart-query`。
- 本地规则没命中特定能力时，`smart-query` 会把用户原话交给 iFinD `/smart_stock_picking`；少数“有啥/怎么样/昵称”口语会先改写成 iFinD 更稳定的正式查询词。
- 只有自然语言入口和命名接口都不够时，才用 `api-call`。

## 总表

| 能力 | 当前状态 | 数据来源 | 主入口 | 备注 |
|------|----------|----------|--------|------|
| 个股实时行情 | 已支持 | iFinD | `smart-query` / `quote-realtime` | 必须鉴权 |
| 个股历史走势 | 已支持 | iFinD | `smart-query` / `quote-history` | 必须鉴权 |
| 单日开盘/收盘/最高/最低 | 已支持 | iFinD | `smart-query` / `quote-history` | 必须鉴权 |
| 大盘 / 指数快照 | 已支持 | iFinD | `smart-query` / `market-snapshot` | 必须鉴权 |
| 涨停数据 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| A 股榜单 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| 成交额榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 涨幅榜 / 跌幅榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 换手率榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 振幅榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 量比榜 | 已支持 | iFinD | `smart-query` | 限制条件保留在自然语言 `searchstring` |
| 基础财务指标 | 已支持 | iFinD | `smart-query` / `fundamental-basic` | 固定查询财务、估值、预测三组模板 |
| 精确财务指标 | 已支持 | iFinD | `smart-query` | 例如营收、毛利率、市盈率、总市值；保留原问题交给 iFinD |
| 个股画像 / 主营业务 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| 资金流相关问法 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking` |
| 公告摘要 / 公告检索 | 已支持 | iFinD | `smart-query` | 走 `/smart_stock_picking`；PDF 原文下载仍需手动接口确认 |
| 研报 / 评级 / 目标价 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 龙虎榜 / 大宗交易 / 异动 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 融资融券 / 北向 / 沪深股通 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 股东 / 持仓 / 机构持仓 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 分红派息 / 送转 / 解禁 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 停牌复牌 / 风险警示 / 退市 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 概念板块 / 题材 / 产业链 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 新股申购 / 中签 / 发行上市 | 已支持 | iFinD | `smart-query` | 保留原始 `searchstring` |
| 交易日 / 休市日 | 已支持 | iFinD | `smart-query` | 走 `/date_sequence`，返回的 `time` 字段为交易日序列 |
| 复杂筛选 / 选股条件组合 | 可自然语言透传 | iFinD | `smart-query` | 透传到 `/smart_stock_picking` |
| 专业接口 | 透传能力 | iFinD | `report-query` / `date-sequence` / `api-call` | 自然语言入口不够时再用 |
| 全市场分位筛选 | 当前无稳定能力 | iFinD 理论可做 | 无 | 例如“前 15% 市值的沪深股票” |

## A. 必须先鉴权

以下能力即使看起来可以从公开网页拿到，也必须通过 iFinD：

- 个股实时行情
- 个股历史走势
- 单日开盘价 / 收盘价 / 高低点
- 大盘 / 指数快照
- 涨停数据
- A 股榜单
- 成交额榜、涨跌幅榜、换手率榜、振幅榜、量比榜
- 基本面、画像、资金流
- 公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股、交易日

如果没有可用 iFinD token，返回 `auth_required`，不要继续查其它来源。

## B. 可选大模型路由

如果配置了 `IFIND_ROUTE_LLM_ENABLED=1` 和 API key，`smart-query` 会先尝试用大模型生成 iFinD 路由计划。

大模型路由的边界：

- 只能输出已支持 intent 和 iFinD payload
- 不能输出公开源 provider
- 不能输出 `fallback_type`
- 低置信度或解析失败时回到本地确定性路由

## C. 复杂自然语言怎么处理

以下问题不要求 Agent 自己拆 payload，先交给 `smart-query`：

- `筛一下新能源车产业链里市盈率低于30且近一个月放量的股票`
- `查一下贵州茅台近三年营收和毛利率`
- `找一下今天主力资金流入靠前的半导体股票`
- `按成交额看一下最近市场最活跃的行业`

处理结果：

- 本地规则能识别的，走稳定路由
- 本地规则识别不了的，走 `generic_smart_query`
- `generic_smart_query` 会调用 `/smart_stock_picking`
- 数据仍然只来自 iFinD

## D. 当前不要假装能做

以下问题当前不要告诉用户“已经支持”：

- `帮我找前15%市值的沪深股票`
- `筛出市值前 20% 且换手率大于 3% 的股票`
- `按多个条件做全市场选股并返回结果`

原因：

- 这类问题超出当前已验证的稳定路由
- 可以先让 `smart-query` 透传给 iFinD
- 如果 iFinD 返回失败，再告诉用户当前没有稳定覆盖，不要自己乱拼 payload

对外回复应使用类似口径：

`当前 tonghuashun-ifind-skill skill 还没有稳定覆盖这类全市场筛选能力。`

## E. Agent 决策口诀

- 所有查询都先确认 iFinD 鉴权
- 用户只要是自然语言查询，先用 `smart-query`
- 不要为了“看起来更专业”先写 `api-call`
- `smart-query` 失败后再看 `endpoint-list`
- 找不到稳定接口就明确说当前未覆盖，不乱猜 payload


path: references/full-examples.md
content: # 全面例子

这份文档专门给 Agent 和开发者看，目标是把当前命令面用一组真实 A 股例子串起来。

说明：

- 所有查询都必须先完成 iFinD 鉴权
- `smart-query` 和稳定路由是当前最推荐的入口
- 常见路由不够时，先看 `endpoint-list` / `endpoint-call`
- `basic-data`、`smart-pick`、`report-query`、`date-sequence` 属于透传 wrapper
- 如果你的 iFinD 账号对某个 endpoint 有更严格的字段要求，以你账号对应文档为准

运行前请先把 `{baseDir}` 替换成 skill 根目录。

## 1. 一套完整流程

### 1.1 官方 refresh_token 鉴权

```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-refresh-token \
  --refresh-token "$IFIND_REFRESH_TOKEN"
```

适用场景：

- 用户已经从 iFinD 超级命令客户端或网页版账号详情复制了 `refresh_token`
- 需要按官方 HTTP 接口方式换取并缓存 `access_token`

### 1.2 手动注入双 token

```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-tokens \
  --access-token "$IFIND_ACCESS_TOKEN" \
  --refresh-token "$IFIND_REFRESH_TOKEN"
```

适用场景：

- 你已经有可用的 `access_token` 和 `refresh_token`

### 1.3 先用自然语言问一个最常见问题

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看看贵州茅台现在股价"
```

这条命令会命中：

- intent: `quote_realtime`
- endpoint: `/real_time_quotation`

### 1.4 再问一个历史走势问题

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看下宁德时代近一个月走势"
```

这条命令会命中：

- intent: `quote_history`
- endpoint: `/cmd_history_quotation`

### 1.5 再问一个榜单问题

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "A股成交额榜前十"
```

这条命令会命中：

- intent: `leaderboard_screen`
- endpoint: `/smart_stock_picking`
- payload: `{"searchstring":"A股成交额榜前十","searchtype":"stock"}`

## 2. `smart-query` 全路由真实例子

### 2.1 个股实时行情

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看看贵州茅台现在股价"
```

关注返回字段：

- `data.intent`
- `data.entity.symbol`
- `data.response`

### 2.2 个股历史走势

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "贵州茅台最近一周表现"
```

关注返回字段：

- `data.intent`
- `data.request.payload.startdate`
- `data.request.payload.enddate`
- `data.response`

### 2.3 大盘 / 指数快照

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "沪深300现在怎么样"
```

关注返回字段：

- `data.intent`
- `data.request.payload.codes`
- `data.response`

### 2.4 基础财务指标

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看看宁德时代基本面"
```

关注返回字段：

- `data.intent`
- `data.request.payload.searchstrings`
- `data.results.financials`
- `data.results.valuation`
- `data.results.forecast`

### 2.5 涨停数据

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "今天的A股涨停数据"
```

关注返回字段：

- `data.intent`
- `data.request.payload.searchstring`
- `data.response`

### 2.6 A 股榜单

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "今日涨幅榜前二十"
```

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "量比榜前十"
```

关注返回字段：

- `data.intent`
- `data.request.payload.searchstring`
- `data.response`

### 2.7 个股画像 / 主营业务

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "贵州茅台主营业务是什么"
```

关注返回字段：

- `data.intent`
- `data.entity.symbol`
- `data.response`

### 2.8 资金流

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "今天主力资金流入前十"
```

关注返回字段：

- `data.intent`
- `data.request.payload.searchstring`
- `data.response`

### 2.9 A 股常见数据查询

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "宁德时代融资余额和北向持股情况"
```

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "贵州茅台最近公告、分红记录和龙虎榜"
```

这类问题不要因为出现股票名就改查实时行情。关注返回字段：

- `data.intent`，通常是 `generic_smart_query`
- `data.request.payload.searchstring`
- `data.response`

### 2.10 复杂自然语言筛选

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票"
```

这类问题不要让 Agent 先拆 endpoint 和 payload。关注返回字段：

- `data.intent`，通常是 `generic_smart_query`
- `data.request.payload.searchstring`
- `data.response`

## 3. 显式稳定命令真实例子

### 3.1 `quote-realtime`

```bash
python3 {baseDir}/scripts/ifind_cli.py quote-realtime --symbol 600519
```

适合：

- 已经知道证券代码
- 只需要实时行情

### 3.2 `quote-history`

```bash
python3 {baseDir}/scripts/ifind_cli.py quote-history \
  --symbol 300750 \
  --days 30
```

```bash
python3 {baseDir}/scripts/ifind_cli.py quote-history \
  --symbol 600004.SH \
  --start-date 2026-04-21 \
  --end-date 2026-04-21
```

适合：

- 已经知道证券代码
- 需要明确日期窗口

### 3.3 `market-snapshot`

```bash
python3 {baseDir}/scripts/ifind_cli.py market-snapshot
python3 {baseDir}/scripts/ifind_cli.py market-snapshot --symbol 沪深300
```

### 3.4 `fundamental-basic`

```bash
python3 {baseDir}/scripts/ifind_cli.py fundamental-basic --symbol 300750
```

## 4. 命名接口目录

查看目录：

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-list
```

按名字调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
  --name real_time_quote \
  --payload '{"codes":"600519.SH","indicators":"open,high,low,latest,changeRatio,change,preClose,volume,amount"}'
```

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
  --name history_quote \
  --payload '{"codes":"600004.SH","indicators":"open,close,high,low,volume","startdate":"2026-04-21","enddate":"2026-04-21"}'
```

## 5. 原始薄封装

```bash
python3 {baseDir}/scripts/ifind_cli.py basic-data \
  --payload '{"codes":"300750.SZ","indicators":"ths_close_price_stock"}'

python3 {baseDir}/scripts/ifind_cli.py smart-pick \
  --payload '{"searchstring":"今天的A股涨停数据","searchtype":"stock"}'

python3 {baseDir}/scripts/ifind_cli.py report-query \
  --payload '{"codes":"300750.SZ"}'

python3 {baseDir}/scripts/ifind_cli.py date-sequence \
  --payload '{"startdate":"2026-04-01","enddate":"2026-04-30"}'
```

## 6. 可选 LLM 路由

```bash
export IFIND_ROUTE_LLM_ENABLED=1
export IFIND_ROUTE_LLM_API_KEY="$OPENAI_API_KEY"
export IFIND_ROUTE_LLM_MODEL="gpt-4o-mini"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "帮我看一下茅台四月以来的日线表现"
```

如果大模型返回低置信度或不可解析结果，skill 会自动回到本地确定性路由。

## 7. 失败处理示例

没有可用 token 时：

```json
{
  "ok": false,
  "error": {
    "type": "auth_required"
  }
}
```

处理方式：

1. 先运行 `auth-set-refresh-token`
2. 鉴权成功后重试原查询
3. 不要使用其它数据源替代


path: references/routing.md
content: # 路由规则

这个 skill 的目标不是把所有 iFinD API 都自动猜出来，而是先把高频问题做成稳定路由。

如果你要先判断“这个能力有没有、该走哪个入口”，先看：

- [能力矩阵](capability-matrix.md)

## 优先顺序

1. 对于常见问题，优先使用 `smart-query`
2. 如果请求已经很明确，也可以直接使用显式稳定命令
3. 如果需要更多已封装接口，先用 `endpoint-list` 查看目录，再用 `endpoint-call`
4. 本地规则没有稳定命中时，`smart-query` 会透传到 iFinD `/smart_stock_picking`
5. 只有在自然语言入口和命名接口目录都未覆盖时，才考虑 `api-call`

## 强制 iFinD 数据源

所有查询都必须先完成 iFinD 鉴权。

- 没有 token 或 refresh 失败：返回 `auth_required`
- iFinD API 返回错误：保留 iFinD 错误并返回
- 账号无接口权限：直接告诉用户 iFinD 权限不足
- 不切换到腾讯财经、东方财富或其它公开源

## 可选 LLM 路由

默认路由是本地确定性规则。配置 `IFIND_ROUTE_LLM_ENABLED=1` 后，`smart-query` 会先调用大模型生成路由计划。

LLM 路由只能输出：

- 已支持 intent
- iFinD endpoint
- iFinD payload
- 股票或指数标的
- 日期窗口

LLM 路由不能输出：

- 非 iFinD provider
- `fallback_type`
- 公开源 URL
- 与 iFinD payload 无关的字段

低置信度或模型失败时，自动回到本地规则。

## 当前内置支持

### 0. 口语简称 / 昵称纠偏

适用说法：

- 茅台咋样
- 宁王今天咋样
- 招行现在多少
- 东财最近走势
- 中芯、迈瑞、药明、工行、平安等高频简称

默认规则：

- 正式中文名优先用 iFinD `/smart_stock_picking` 解析代码
- 高频口语简称允许用内置小型别名纠偏，避免 iFinD 把昵称误识别成无关股票
- 纠偏只用于确定 iFinD 证券代码；实际数据仍调用 iFinD 行情、历史或 `/smart_stock_picking`

### 1. 个股最新价

适用说法：

- 某股票现在股价
- 最新价
- 现价
- 行情

实际接口：

- `/real_time_quotation`

### 2. 个股历史走势

适用说法：

- 近一个月走势
- 最近一周
- 历史行情
- K线
- 指定日期开盘价、收盘价、最高价、最低价

实际接口：

- `/cmd_history_quotation`

默认规则：

- 没给时间时，默认最近 30 天

### 3. 大盘或指数快照

适用说法：

- 看一下大盘
- 看指数
- 看盘面

默认指数包：

- 上证指数 `000001.SH`
- 深证成指 `399001.SZ`
- 创业板指 `399006.SZ`
- 沪深300 `000300.SH`

实际接口：

- `/real_time_quotation`

### 4. 基础财务指标

适用说法：

- 基本面
- 财务
- 估值
- 市盈率 / 市净率 / 市值

实际接口：

- `/smart_stock_picking`

当前会固定查询三组模板：

- 财务指标
- 估值指标
- 预测指标

### 5. 涨停数据

适用说法：

- 今天的A股涨停数据
- 今日涨停
- 涨停板
- 封板数据

实际接口：

- `/smart_stock_picking`

默认规则：

- 直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`

### 6. A 股榜单查询

适用说法：

- A股成交额榜前十
- 今日涨幅榜
- 跌幅榜前二十
- 换手率排行
- 振幅榜
- 量比榜

实际接口：

- `/smart_stock_picking`

默认规则：

- 直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`
- 排名方向和数量保留在自然语言里交给 iFinD 解析

### 7. 个股画像 / 主营业务

适用说法：

- 贵州茅台主营业务是什么
- 宁德时代公司简介
- 这家公司是做什么的

实际接口：

- `/smart_stock_picking`

默认规则：

- 先解析股票标的
- 再把用户原始问题作为 `searchstring`

### 8. 资金流问题

适用说法：

- 今天主力资金流入前十
- 某股票资金流向
- 资金净流入排行

实际接口：

- `/smart_stock_picking`

默认规则：

- 直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`

### 9. A 股常见自然语言查询

适用说法：

- 贵州茅台最近公告
- 贵州茅台分红记录
- 贵州茅台龙虎榜
- 宁德时代融资余额和北向持股情况
- 宁德时代限售解禁安排
- 宁德时代所属概念和产业链
- 明天A股有哪些新股申购

实际接口：

- `/smart_stock_picking`

默认规则：

- 默认把用户原始问题作为 `searchstring`
- 如果用户说“公告有啥、分红怎么样、有啥研报、啥消息”这类口语，先改写成 iFinD 更稳定的正式词，例如“最近公告、分红记录、研报”
- `searchtype` 固定为 `stock`
- intent: `generic_smart_query`
- 不要因为问题里出现了股票名，就把公告、分红、龙虎榜、两融、北向持股等问法误路由成实时行情

### 10. 交易日 / 休市日

适用说法：

- 下一个交易日是什么时候
- 下个交易日是哪天
- 明天开不开盘
- 今天A股休市吗

实际接口：

- `/date_sequence`

默认规则：

- 使用上证指数 `000001.SH` 的 iFinD 日期序列能力
- `functionpara` 固定为 `{"Days": "Tradedays", "Fill": "Omit"}`
- 返回里的 `time` 字段就是 iFinD 给出的交易日序列

### 11. 复杂自然语言泛化查询

适用说法：

- 筛一下新能源车产业链里市盈率低于30且近一个月放量的股票
- 查一下贵州茅台近三年营收和毛利率
- 找一下半导体行业今天主力资金流入靠前的股票
- 看一下最近成交额活跃的行业

实际接口：

- `/smart_stock_picking`

默认规则：

- 本地规则没命中稳定路由时，直接把用户原始问题作为 `searchstring`
- `searchtype` 固定为 `stock`
- intent: `generic_smart_query`
- 不要求 Agent 先手写 endpoint 或 payload

## 什么时候不要猜

以下情况不要直接乱拼 `api-call`：

- 公告 PDF 下载
- 原文下载链接
- skill 里没写过的细分接口
- 你不确定 payload 结构

这时应该：

1. 回到本文件和 `usage.md`
2. 如果仍然没有明确映射，告诉用户：

`当前 tonghuashun-ifind-skill skill 没有稳定覆盖这个 iFinD 能力。`

如果 `smart-query` 和 iFinD 自然语言透传都失败，但 skill 里可能已经封过接口名，先执行：

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-list
```

如果目录里已经有目标能力，对应执行：

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call --name history_quote --payload '{...}'
```

## 手动 API 调用

只有在你已经明确知道目标 endpoint 和 payload 的情况下，才使用：

```bash
python3 {baseDir}/scripts/ifind_cli.py api-call --endpoint /xxx --payload '{...}'
```


path: references/usage.md
content: # 使用说明

运行命令前，请先把 `{baseDir}` 替换成这个 skill 的目录。

如果要先判断“这件事到底能不能做、该走哪个入口”，先看：

- [能力矩阵](capability-matrix.md)

如果你要一份覆盖所有命令面的可抄完整例子，先看：

- [全面例子](full-examples.md)

## Skill 地址

- ClawHub / OpenClaw：
  `https://clawhub.ai/etherstrings/tonghuashun-ifind`
- Hermes Agent GitHub skill 源：
  `https://github.com/Etherstrings/tonghuashun-ifind-skill/tree/main/tonghuashun-ifind-skill`

补充：

- 当前发布版本：`0.5.1`
- Hermes 侧直接使用 GitHub skill 源，不再指向历史分支 PR

## 必须先完成 iFinD 鉴权

这个版本强制所有数据来自同花顺 iFinD。

- 没有可用 token 时，查询命令返回 `auth_required`
- `refresh_token` 续期失败时，不再继续查其它来源
- iFinD API 返回错误、权限不足或账号无对应接口权限时，直接返回 iFinD 错误
- 不使用腾讯财经、东方财富或其它公开源补数据

官方鉴权主路径：

1. 用户登录 iFinD 超级命令客户端或网页版账号详情，复制 `refresh_token`
2. skill 调用 `/get_access_token` 换取 `access_token`
3. 数据接口请求头带 `access_token`

Agent 对用户的标准引导：

```text
请先打开同花顺 iFinD 超级命令客户端，进入账号详情，复制 refresh_token。
如果你用网页端，可以打开：
https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails
登录后在账号详情里复制 refresh_token。复制后发给我，我会只用它换取 access_token 并缓存，不需要你的同花顺用户名或密码。
```

官方文档入口：

- iFinD HTTP 接口使用说明 / 鉴权说明：`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/help-center/deploy.html`
- iFinD Python HTTP 示例：`https://quantapi.51ifind.com/gwstatic/static/ds_web/quantapi-web/example.html`
- iFinD 网页版超级命令账号详情：`https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/super-command-web/index.html#/AccountDetails`

## 安装给 Agent 的最短路径

如果你是安装 Agent，执行：

```bash
bash scripts/install_skill.sh
```

安装目标必须是：

```text
~/.openclaw/workspace/skills/tonghuashun-ifind-skill
```

安装后不要先写 API payload，先让用户完成鉴权，然后用 `smart-query`。

## 官方 refresh_token 鉴权

用户提供 `refresh_token` 后，Agent 执行：

```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-refresh-token \
  --refresh-token "$IFIND_REFRESH_TOKEN"
```

## 手动注入双 token

```bash
python3 {baseDir}/scripts/ifind_cli.py auth-set-tokens \
  --access-token "$IFIND_ACCESS_TOKEN" \
  --refresh-token "$IFIND_REFRESH_TOKEN"
```

## 可选大模型路由

默认使用本地确定性路由。需要强化自然语言解析时，可以配置 OpenAI-compatible Chat Completions 服务：

```bash
export IFIND_ROUTE_LLM_ENABLED=1
export IFIND_ROUTE_LLM_API_KEY="$OPENAI_API_KEY"
export IFIND_ROUTE_LLM_MODEL="gpt-4o-mini"
```

可选变量：

- `IFIND_ROUTE_LLM_BASE_URL`
- `IFIND_ROUTE_LLM_TIMEOUT`
- `IFIND_ROUTE_LLM_MIN_CONFIDENCE`

大模型只负责输出 iFinD 路由计划；低置信度或模型调用失败时会回到本地规则。

## 原始 API 调用

```bash
python3 {baseDir}/scripts/ifind_cli.py api-call \
  --endpoint /basic_data_service \
  --payload '{"codes":"300750.SZ","indicators":"ths_close_price_stock","functionpara":{"Interval":"D","StartDate":"2025-01-01","EndDate":"2025-01-31"}}'
```

## 命名接口目录

如果你不想让 Agent 直接手写 endpoint 字符串，先看当前已封装目录：

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-list
```

当前目录会返回一组带说明和样例 payload 的名字，例如：

- `basic_data`
- `smart_pick`
- `report_query`
- `date_sequence`
- `real_time_quote`
- `history_quote`
- `limit_up_screen`
- `leaderboard_screen`
- `fundamental_basic`
- `entity_profile`
- `capital_flow`
- `a_share_common_query`
- `generic_smart_query`

然后再按名字调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
  --name real_time_quote \
  --payload '{"codes":"600519.SH,000300.SH","indicators":"open,high,low,latest,changeRatio,change,preClose,volume,amount,turnoverRatio,volumeRatio,amplitude,pb"}'
```

```bash
python3 {baseDir}/scripts/ifind_cli.py endpoint-call \
  --name history_quote \
  --payload '{"codes":"600004.SH","indicators":"open,close,high,low,volume","startdate":"2026-04-21","enddate":"2026-04-21"}'
```

## 常见查询主入口

优先让 Agent 用 `smart-query`，直接把用户的问题交给 skill 路由：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看看贵州茅台现在股价"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看下宁德时代近一个月走势"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看一下大盘"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "看看宁德时代基本面"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "今天的A股涨停数据"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "A股成交额榜前十"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "贵州茅台主营业务是什么"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "今天主力资金流入前十"

python3 {baseDir}/scripts/ifind_cli.py smart-query \
  --query "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票"
```

## 显式稳定命令

```bash
python3 {baseDir}/scripts/ifind_cli.py quote-realtime --symbol 600519
python3 {baseDir}/scripts/ifind_cli.py quote-history --symbol 300750 --days 30
python3 {baseDir}/scripts/ifind_cli.py market-snapshot
python3 {baseDir}/scripts/ifind_cli.py market-snapshot --symbol 沪深300
python3 {baseDir}/scripts/ifind_cli.py fundamental-basic --symbol 300750
```

说明：

- `quote-realtime`、`quote-history`、`market-snapshot` 只走 iFinD
- 涨停数据和 A 股榜单只走 iFinD `/smart_stock_picking`
- `fundamental-basic`、个股画像、资金流查询只走 iFinD
- 复杂自然语言查询会透传到 iFinD `/smart_stock_picking`

## 保留的原始薄封装

```bash
python3 {baseDir}/scripts/ifind_cli.py basic-data --payload '{"codes":"300750.SZ"}'
python3 {baseDir}/scripts/ifind_cli.py smart-pick --payload '{"conditions":[]}'
python3 {baseDir}/scripts/ifind_cli.py report-query --payload '{"codes":"300750.SZ"}'
python3 {baseDir}/scripts/ifind_cli.py date-sequence --payload '{"startdate":"2025-01-01","enddate":"2025-01-31"}'
```

## 失败处理规则

如果没有官方 `refresh_token`，就让用户先登录 iFinD 超级命令客户端或网页版账号详情复制，不要尝试其它数据源。

如果查询命令返回 `auth_required`：

1. 先运行 `auth-set-refresh-token`
2. 如果用户已经有双 token，再运行 `auth-set-tokens`
3. 不要用公开源替代
4. 鉴权成功后重试原查询

如果 iFinD API 返回业务错误：

1. 保留返回里的 `errorcode` 和 `errmsg`
2. 告诉用户这是 iFinD 调用失败或权限问题
3. 不要自动切换到非同花顺数据源

如果 `smart-query` 返回需要手动查接口：

1. 先读 `references/routing.md`
2. 再看 `references/use-cases.md` 里是否已有类似问法
3. 再决定是否使用 `api-call`
4. 如果 `endpoint-list` 里已有合适名字，优先 `endpoint-call`
5. 如果文档里也找不到合适接口，就明确告诉用户当前 skill 未覆盖该 iFinD 能力


path: references/use-cases.md
content: # 常见 Use Cases

这份文件是给 Agent 看的快速案例库。

原则：

- 先找和用户问题最接近的案例
- 先用 [能力矩阵](capability-matrix.md) 判断当前问题属于哪一类能力
- 所有数据查询都必须先完成 iFinD 鉴权
- 能用 `smart-query` 时不要先手写 `api-call`
- 如果案例和当前请求明显不匹配，就回到 `routing.md`

## 1. 个股最新价

用户问法：

- `看看贵州茅台现在股价`
- `宁德时代最新价`
- `查一下 600519 行情`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看看贵州茅台现在股价"
```

预期路由：

- intent: `quote_realtime`
- endpoint: `/real_time_quotation`
- if iFinD fail: 返回 iFinD 错误，不使用其它数据源

## 2. 个股近一段时间走势

用户问法：

- `看下宁德时代近一个月走势`
- `贵州茅台最近一周表现`
- `看 300750 历史行情`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看下宁德时代近一个月走势"
```

预期路由：

- intent: `quote_history`
- endpoint: `/cmd_history_quotation`
- if iFinD fail: 返回 iFinD 错误，不使用其它数据源

## 3. 大盘或指数快照

用户问法：

- `看一下大盘`
- `看看指数`
- `沪深300现在怎么样`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看一下大盘"
```

预期路由：

- intent: `market_snapshot`
- endpoint: `/real_time_quotation`
- if iFinD fail: 返回 iFinD 错误，不使用其它数据源

默认指数包：

- 上证指数
- 深证成指
- 创业板指
- 沪深300

## 4. 基础财务指标

用户问法：

- `看看宁德时代基本面`
- `贵州茅台估值怎么样`
- `看下 300750 的财务和市盈率`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "看看宁德时代基本面"
```

预期路由：

- intent: `fundamental_basic`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误或权限问题

## 5. 涨停数据

用户问法：

- `今天的A股涨停数据`
- `今日涨停`
- `涨停板`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "今天的A股涨停数据"
```

预期路由：

- intent: `limit_up_screen`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误，不使用其它数据源

## 6. A 股榜单

用户问法：

- `A股成交额榜前十`
- `今日涨幅榜`
- `跌幅榜前二十`
- `量比榜`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "A股成交额榜前十"
```

预期路由：

- intent: `leaderboard_screen`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误，不使用其它数据源

## 7. 个股画像 / 主营业务

用户问法：

- `贵州茅台主营业务是什么`
- `宁德时代公司简介`
- `这家公司是做什么的`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "贵州茅台主营业务是什么"
```

预期路由：

- intent: `entity_profile`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误或权限问题

## 8. 资金流

用户问法：

- `今天主力资金流入前十`
- `主力资金净流入排行`
- `宁德时代资金流向`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "今天主力资金流入前十"
```

预期路由：

- intent: `capital_flow`
- endpoint: `/smart_stock_picking`
- if iFinD fail: 返回 iFinD 错误或权限问题

## 9. A 股常见数据查询

用户问法：

- `贵州茅台最近公告`
- `贵州茅台分红记录`
- `贵州茅台龙虎榜`
- `宁德时代融资余额和北向持股情况`
- `宁德时代限售解禁安排`
- `宁德时代所属概念和产业链`
- `明天A股有哪些新股申购`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "宁德时代融资余额和北向持股情况"
```

预期路由：

- intent: `generic_smart_query`
- endpoint: `/smart_stock_picking`
- 默认把用户原问题作为 `searchstring`；口语“有啥/怎么样/啥消息”会先改写成 iFinD 更稳定的正式查询词
- if iFinD fail: 返回 iFinD 错误，不使用其它数据源

## 10. 交易日 / 休市日

用户问法：

- `下一个交易日是什么时候`
- `下个交易日是哪天`
- `明天开不开盘`
- `今天A股休市吗`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "下个交易日是哪天"
```

预期路由：

- intent: `trading_calendar`
- endpoint: `/date_sequence`
- 返回数据中的 `time` 字段为 iFinD 交易日序列

处理原则：

- 不要把这类问法误交给 `/smart_stock_picking`
- 不要让 Agent 自己猜休市日；以 iFinD `/date_sequence` 返回的 `time` 为准

## 11. 复杂自然语言筛选

用户问法：

- `筛一下新能源车产业链里市盈率低于30且近一个月放量的股票`
- `查一下贵州茅台近三年营收和毛利率`
- `找一下今天主力资金流入靠前的半导体股票`

建议调用：

```bash
python3 {baseDir}/scripts/ifind_cli.py smart-query --query "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票"
```

预期路由：

- intent: `generic_smart_query`
- endpoint: `/smart_stock_picking`
- 直接把用户原问题作为 `searchstring`

处理原则：

- 不要让 Agent 先手写 `api-call`
- 先让 iFinD 自然语言能力处理
- iFinD 返回失败后，再考虑 `endpoint-list` 或明确说明当前未覆盖

## 12. 不要乱猜的请求

用户问法：

- `帮我找贵州茅台公告PDF下载链接并按日期排序`
- `把所有年报原文下载地址列出来`
- `找公告附件全文`

处理方式：

1. 先运行 `smart-query`
2. 如果返回 `manual_api_lookup_required`，就读 `routing.md`
3. 如果仍没有明确接口，就直接告诉用户：

`当前 tonghuashun-ifind-skill skill 没有稳定覆盖这个 iFinD 能力。`


path: scripts/ifind_cli.py
content: from __future__ import annotations

from datetime import date
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import argparse
import json
from pathlib import Path
import re
import sys


_RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if _RUNTIME_DIR.is_dir():
    runtime_path = str(_RUNTIME_DIR)
    if runtime_path not in sys.path:
        sys.path.insert(0, runtime_path)


DEFAULT_BASE_URL = "https://quantapi.51ifind.com/api/v1"


def main(argv: list[str] | None = None) -> int:
    result = run_command(sys.argv[1:] if argv is None else argv)
    print(json.dumps(result, ensure_ascii=False))
    return 0 if result["ok"] else 1


def run_command(argv: list[str]) -> dict[str, object]:
    from tonghuashun_ifind_skill.client import build_envelope

    parser = _build_parser()
    try:
        args = parser.parse_args(argv)
    except SystemExit:
        return build_envelope(
            ok=False,
            endpoint="/cli",
            token_source="cli",
            error_type="invalid_request",
            error_message="invalid arguments",
        )

    state_path = (
        Path(args.state_path).expanduser()
        if args.state_path
        else _default_state_path()
    )

    try:
        if args.command == "auth-set-tokens":
            return _handle_auth_set_tokens(args, state_path)
        if args.command == "auth-set-refresh-token":
            return _handle_auth_set_refresh_token(args, state_path)
        if args.command == "endpoint-list":
            return _handle_endpoint_list()
        if args.command in {
            "api-call",
            "endpoint-call",
            "basic-data",
            "smart-pick",
            "report-query",
            "date-sequence",
        }:
            return _handle_api_command(args, state_path)
        if args.command in {
            "smart-query",
            "quote-realtime",
            "quote-history",
            "market-snapshot",
            "fundamental-basic",
        }:
            return _handle_routed_query_command(args, state_path)
    except Exception as exc:
        return build_envelope(
            ok=False,
            endpoint=_command_endpoint(args),
            token_source="cli",
            error_type="runtime_failed",
            error_message=_sanitize_exception(exc),
        )

    return build_envelope(
        ok=False,
        endpoint="/cli",
        token_source="cli",
        error_type="invalid_request",
        error_message="unknown command",
    )


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="ifind-cli")
    parser.add_argument(
        "--state-path",
        default=None,
        help="Path to token state storage",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    auth_set = subparsers.add_parser("auth-set-tokens")
    auth_set.add_argument("--access-token", required=True)
    auth_set.add_argument("--refresh-token", required=True)
    auth_set.add_argument("--expires-at", default=None)

    auth_refresh = subparsers.add_parser("auth-set-refresh-token")
    auth_refresh.add_argument("--refresh-token", required=True)
    auth_refresh.add_argument("--base-url", default=DEFAULT_BASE_URL)

    api_common = argparse.ArgumentParser(add_help=False)
    api_common.add_argument("--base-url", default=DEFAULT_BASE_URL)
    api_common.add_argument("--payload", default="{}")

    api_call = subparsers.add_parser("api-call", parents=[api_common])
    api_call.add_argument("--endpoint", required=True)

    subparsers.add_parser("endpoint-list")

    endpoint_call = subparsers.add_parser("endpoint-call", parents=[api_common])
    endpoint_call.add_argument("--name", required=True)

    subparsers.add_parser("basic-data", parents=[api_common])
    subparsers.add_parser("smart-pick", parents=[api_common])
    subparsers.add_parser("report-query", parents=[api_common])
    subparsers.add_parser("date-sequence", parents=[api_common])

    smart_query = subparsers.add_parser("smart-query", parents=[api_common])
    smart_query.add_argument("--query", required=True)

    quote_realtime = subparsers.add_parser("quote-realtime", parents=[api_common])
    quote_realtime.add_argument("--symbol", required=True)

    quote_history = subparsers.add_parser("quote-history", parents=[api_common])
    quote_history.add_argument("--symbol", required=True)
    quote_history.add_argument("--start-date", default=None)
    quote_history.add_argument("--end-date", default=None)
    quote_history.add_argument("--days", type=int, default=30)

    market_snapshot = subparsers.add_parser("market-snapshot", parents=[api_common])
    market_snapshot.add_argument("--symbol", default=None)

    fundamental_basic = subparsers.add_parser("fundamental-basic", parents=[api_common])
    fundamental_basic.add_argument("--symbol", required=True)

    return parser


def _handle_auth_set_tokens(
    args: argparse.Namespace,
    state_path: Path,
) -> dict[str, object]:
    from tonghuashun_ifind_skill.client import build_envelope
    from tonghuashun_ifind_skill.models import TokenBundle
    from tonghuashun_ifind_skill.state import TokenStateStore

    expires_at = args.expires_at or _default_expiry()
    bundle = TokenBundle(
        access_token=args.access_token,
        refresh_token=args.refresh_token,
        expires_at=expires_at,
    )
    TokenStateStore(state_path).save(bundle)
    return build_envelope(
        ok=True,
        endpoint="/auth/set-tokens",
        token_source="manual",
        data={"stored": True, "expires_at": expires_at},
    )


def _handle_auth_set_refresh_token(
    args: argparse.Namespace,
    state_path: Path,
) -> dict[str, object]:
    from tonghuashun_ifind_skill.auth import exchange_refresh_token
    from tonghuashun_ifind_skill.client import build_envelope
    from tonghuashun_ifind_skill.state import TokenStateStore

    bundle = exchange_refresh_token(
        args.refresh_token,
        base_url=args.base_url,
    )
    TokenStateStore(state_path).save(bundle)
    return build_envelope(
        ok=True,
        endpoint="/auth/set-refresh-token",
        token_source="refresh",
        data={"stored": True, "expires_at": bundle.expires_at},
    )


def _handle_api_command(
    args: argparse.Namespace,
    state_path: Path,
) -> dict[str, object]:
    from tonghuashun_ifind_skill.client import IFindClient
    from tonghuashun_ifind_skill.client import build_envelope
    from tonghuashun_ifind_skill.endpoint_catalog import get_endpoint_spec

    payload = _parse_payload(args.payload)
    if args.command == "endpoint-call":
        try:
            spec = get_endpoint_spec(args.name)
        except ValueError as exc:
            return build_envelope(
                ok=False,
                endpoint="/endpoint_catalog",
                token_source="cli",
                error_type="invalid_request",
                error_message=str(exc),
            )

    auth = _build_auth_manager(
        state_path=state_path,
        base_url=args.base_url,
    )
    bundle, token_source = auth.resolve_tokens()
    client = IFindClient(base_url=args.base_url)

    if args.command == "api-call":
        return client.api_call(
            args.endpoint,
            payload,
            bundle.access_token,
            token_source,
        )
    if args.command == "endpoint-call":
        return client.call_named_endpoint(
            spec.name,
            payload,
            bundle.access_token,
            token_source,
        )
    if args.command == "basic-data":
        return client.basic_data(payload, bundle.access_token, token_source)
    if args.command == "smart-pick":
        return client.smart_stock_picking(payload, bundle.access_token, token_source)
    if args.command == "report-query":
        return client.report_query(payload, bundle.access_token, token_source)
    if args.command == "date-sequence":
        return client.date_sequence(payload, bundle.access_token, token_source)

    return build_envelope(
        ok=False,
        endpoint=_command_endpoint(args),
        token_source="cli",
        error_type="invalid_request",
        error_message="unknown api command",
    )


def _handle_endpoint_list() -> dict[str, object]:
    from tonghuashun_ifind_skill.client import build_envelope
    from tonghuashun_ifind_skill.endpoint_catalog import list_endpoint_specs

    return build_envelope(
        ok=True,
        endpoint="/endpoint_catalog",
        token_source="cli",
        data={
            "endpoints": [spec.to_dict() for spec in list_endpoint_specs()],
        },
    )


def _handle_routed_query_command(
    args: argparse.Namespace,
    state_path: Path,
) -> dict[str, object]:
    from tonghuashun_ifind_skill.client import IFindClient
    from tonghuashun_ifind_skill.client import build_envelope
    from tonghuashun_ifind_skill.llm_routing import build_llm_route_plan
    from tonghuashun_ifind_skill.routing import build_history_plan
    from tonghuashun_ifind_skill.routing import build_market_snapshot_plan
    from tonghuashun_ifind_skill.routing import build_realtime_plan
    from tonghuashun_ifind_skill.routing import build_route_plan
    from tonghuashun_ifind_skill.routing import extract_entity_from_search_payload
    from tonghuashun_ifind_skill.routing import resolve_common_index_entity

    if args.command == "smart-query" and _is_blank_or_punctuation_query(args.query):
        return build_envelope(
            ok=False,
            endpoint="/manual_lookup",
            token_source="cli",
            error_type="manual_api_lookup_required",
            error_message="请输入明确的股票、指数、指标、日期或筛选条件；空白或纯标点无法路由到 iFinD。",
            data={
                "intent": "manual_lookup",
                "note": "blank_or_punctuation_query",
            },
        )

    auth = _build_auth_manager(
        state_path=state_path,
        base_url=args.base_url,
    )
    client = IFindClient(base_url=args.base_url)
    auth_cache: dict[str, object] = {}

    def ensure_auth() -> tuple[object, str]:
        bundle = auth_cache.get("bundle")
        token_source = auth_cache.get("token_source")
        if bundle is not None and isinstance(token_source, str):
            return bundle, token_source
        resolved_bundle, resolved_token_source = auth.resolve_tokens()
        auth_cache["bundle"] = resolved_bundle
        auth_cache["token_source"] = resolved_token_source
        return resolved_bundle, resolved_token_source

    try:
        bundle, token_source = ensure_auth()
    except Exception as exc:
        return build_envelope(
            ok=False,
            endpoint=_command_endpoint(args),
            token_source="cli",
            error_type="auth_required",
            error_message=_auth_required_message(exc),
        )

    def entity_lookup(text: str):
        common_index = resolve_common_index_entity(text)
        if common_index is not None:
            return common_index
        payload = {
            "searchstring": f"{text} 股票代码 股票简称",
            "searchtype": "stock",
        }
        result = client.smart_stock_picking(payload, bundle.access_token, token_source)
        if not result.get("ok"):
            return None
        raw_payload = result.get("data")
        if not isinstance(raw_payload, dict):
            return None
        entity = extract_entity_from_search_payload(text, raw_payload)
        if entity is not None:
            return entity
        return None

    if args.command == "smart-query":
        plan = build_llm_route_plan(
            args.query,
            entity_lookup=entity_lookup,
            today=date.today(),
        )
        if plan is None:
            plan = build_route_plan(
                args.query,
                entity_lookup=entity_lookup,
                today=date.today(),
            )
    elif args.command == "quote-realtime":
        plan = build_route_plan(
            f"{args.symbol} 最新价",
            entity_lookup=entity_lookup,
            today=date.today(),
        )
    elif args.command == "quote-history":
        plan = build_route_plan(
            f"{args.symbol} 近{args.days}天走势",
            entity_lookup=entity_lookup,
            today=date.today(),
        )
        if plan.intent == "quote_history" and plan.entity is not None:
            plan = build_history_plan(
                plan.entity,
                query=f"{args.symbol} 近{args.days}天走势",
                today=date.today(),
                start_date=args.start_date,
                end_date=args.end_date,
            )
    elif args.command == "market-snapshot":
        plan = build_market_snapshot_plan(args.symbol)
    elif args.command == "fundamental-basic":
        plan = build_route_plan(
            f"{args.symbol} 基本面",
            entity_lookup=entity_lookup,
            today=date.today(),
        )
    else:
        return build_envelope(
            ok=False,
            endpoint="/cli",
            token_source="cli",
            error_type="invalid_request",
            error_message="unknown routed command",
        )

    if plan.intent == "manual_lookup":
        return build_envelope(
            ok=False,
            endpoint="/manual_lookup",
            token_source="cli",
            error_type="manual_api_lookup_required",
            error_message=plan.note or "manual lookup required",
            data={
                "intent": plan.intent,
                "note": plan.note,
            },
        )

    if args.command == "fundamental-basic" or plan.intent == "fundamental_basic":
        return _execute_fundamental_plan(
            client=client,
            access_token=bundle.access_token,
            token_source=token_source,
            plan=plan,
        )

    result = client.api_call(
        plan.endpoint or "/",
        plan.payload or {},
        bundle.access_token,
        token_source,
    )
    return _attach_route_metadata(result, plan)


def _build_auth_manager(
    *,
    state_path: Path,
    base_url: str,
) -> "AuthManager":
    from tonghuashun_ifind_skill.auth import AuthManager
    from tonghuashun_ifind_skill.auth import exchange_refresh_token
    from tonghuashun_ifind_skill.models import TokenBundle

    def refresh_exchange(refresh_token: str) -> TokenBundle:
        return exchange_refresh_token(
            refresh_token,
            base_url=base_url,
        )

    return AuthManager.create(
        state_path=state_path,
        refresh_exchange=refresh_exchange,
    )


def _parse_payload(payload: str) -> dict[str, object]:
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise ValueError("payload must be valid JSON") from exc
    if not isinstance(data, dict):
        raise ValueError("payload must be a JSON object")
    return data


def _default_state_path() -> Path:
    return Path.home() / ".openclaw" / "tonghuashun-ifind-skill" / "token_state.json"


def _default_expiry() -> str:
    expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
    return expires_at.replace(microsecond=0).isoformat().replace("+00:00", "Z")


def _sanitize_exception(exc: Exception) -> str:
    return f"request failed: {exc.__class__.__name__}"


def _is_blank_or_punctuation_query(query: str) -> bool:
    return not re.search(r"[A-Za-z0-9\u4e00-\u9fff]", query or "")


def _auth_required_message(exc: Exception) -> str:
    return (
        "iFinD authentication is required before querying data. "
        "Ask the user to open the iFinD Super Command client account details page, "
        "or open https://quantapi.10jqka.com.cn/gwstatic/static/ds_web/"
        "super-command-web/index.html#/AccountDetails, log in, copy refresh_token, "
        "then run auth-set-refresh-token. Do not ask for the iFinD username or "
        "password. If the user already has both tokens, run auth-set-tokens. Detail: "
        f"{_sanitize_exception(exc)}"
    )


def _attach_route_metadata(
    result: dict[str, object],
    plan,
    *,
    response_data: object | None = None,
    note: str | None = None,
    provider: dict[str, object] | None = None,
) -> dict[str, object]:
    effective_response = result.get("data") if response_data is None else response_data
    effective_provider = provider
    if effective_provider is None and isinstance(effective_response, dict):
        maybe_provider = effective_response.get("provider")
        if isinstance(maybe_provider, dict):
            effective_provider = maybe_provider
            effective_response = {
                key: value
                for key, value in effective_response.items()
                if key != "provider"
            }
    result["data"] = {
        "intent": plan.intent,
        "entity": None if plan.entity is None else {
            "raw": plan.entity.raw,
            "symbol": plan.entity.symbol,
            "name": plan.entity.name,
            "entity_type": plan.entity.entity_type,
        },
        "request": {"payload": plan.payload},
        "response": effective_response,
        "note": note if note is not None else plan.note,
    }
    if effective_provider is not None:
        result["data"]["provider"] = effective_provider
    return result


def _execute_fundamental_plan(
    *,
    client,
    access_token: str,
    token_source: str,
    plan,
) -> dict[str, object]:
    from tonghuashun_ifind_skill.client import build_envelope

    payload = plan.payload or {}
    searchstrings = payload.get("searchstrings")
    searchtype = payload.get("searchtype", "stock")
    if not isinstance(searchstrings, list) or not searchstrings:
        return build_envelope(
            ok=False,
            endpoint="/smart_stock_picking",
            token_source=token_source,
            error_type="invalid_request",
            error_message="missing searchstrings for fundamental route",
        )

    labels = ("financials", "valuation", "forecast")
    results: dict[str, object] = {}
    partial_failures: list[str] = []
    any_success = False
    errors: dict[str, object] = {}

    for label, searchstring in zip(labels, searchstrings):
        result = client.smart_stock_picking(
            {"searchstring": searchstring, "searchtype": searchtype},
            access_token,
            token_source,
        )
        if result.get("ok"):
            any_success = True
            results[label] = result.get("data")
        else:
            partial_failures.append(label)
            errors[label] = result.get("error")

    if not any_success:
        return build_envelope(
            ok=False,
            endpoint="/smart_stock_picking",
            token_source=token_source,
            error_type="api_failed",
            error_message="all fundamental queries failed",
            data={
                "intent": plan.intent,
                "entity": None if plan.entity is None else {
                    "raw": plan.entity.raw,
                    "symbol": plan.entity.symbol,
                    "name": plan.entity.name,
                    "entity_type": plan.entity.entity_type,
                },
                "request": {"payload": plan.payload},
                "partial_failures": partial_failures,
                "errors": errors,
            },
        )

    return build_envelope(
        ok=True,
        endpoint="/smart_stock_picking",
        token_source=token_source,
        data={
            "intent": plan.intent,
            "entity": None if plan.entity is None else {
                "raw": plan.entity.raw,
                "symbol": plan.entity.symbol,
                "name": plan.entity.name,
                "entity_type": plan.entity.entity_type,
            },
            "request": {"payload": plan.payload},
            "results": results,
            "partial_failures": partial_failures,
            "errors": errors,
        },
    )


def _command_endpoint(args: argparse.Namespace) -> str:
    if args.command == "auth-set-refresh-token":
        return "/auth/set-refresh-token"
    if args.command == "auth-set-tokens":
        return "/auth/set-tokens"
    if args.command == "api-call":
        endpoint = getattr(args, "endpoint", "")
        return endpoint if endpoint else "/"
    if args.command == "endpoint-list":
        return "/endpoint_catalog"
    if args.command == "endpoint-call":
        return "/endpoint_catalog"
    if args.command == "basic-data":
        return "/basic_data_service"
    if args.command == "smart-pick":
        return "/smart_stock_picking"
    if args.command == "report-query":
        return "/report_query"
    if args.command == "date-sequence":
        return "/date_sequence"
    if args.command == "smart-query":
        return "/smart_query"
    if args.command == "quote-realtime":
        return "/real_time_quotation"
    if args.command == "quote-history":
        return "/cmd_history_quotation"
    if args.command == "market-snapshot":
        return "/real_time_quotation"
    if args.command == "fundamental-basic":
        return "/smart_stock_picking"
    return "/cli"


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/runtime/tonghuashun_ifind_skill/__init__.py
content: """Runtime package for the Tonghuashun iFind skill."""


path: scripts/runtime/tonghuashun_ifind_skill/auth.py
content: from __future__ import annotations

from collections.abc import Callable
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from pathlib import Path
from typing import Literal

import requests

from tonghuashun_ifind_skill.models import TokenBundle
from tonghuashun_ifind_skill.models import format_timestamp
from tonghuashun_ifind_skill.state import TokenStateStore


class AuthManager:
    def __init__(
        self,
        *,
        state_store: TokenStateStore,
        refresh_exchange: Callable[[str], TokenBundle],
    ) -> None:
        self.state_store = state_store
        self.refresh_exchange = refresh_exchange

    @classmethod
    def for_test(
        cls,
        *,
        state_path: Path,
        refresh_exchange: Callable[[str], TokenBundle],
    ) -> "AuthManager":
        return cls(
            state_store=TokenStateStore(state_path),
            refresh_exchange=refresh_exchange,
        )

    @classmethod
    def create(
        cls,
        *,
        state_path: Path,
        refresh_exchange: Callable[[str], TokenBundle],
    ) -> "AuthManager":
        return cls(
            state_store=TokenStateStore(state_path),
            refresh_exchange=refresh_exchange,
        )

    def resolve_tokens(
        self,
    ) -> tuple[TokenBundle, Literal["cache", "refresh"]]:
        bundle = self.state_store.load()
        if bundle is not None and not bundle.is_stale():
            return bundle, "cache"

        if bundle is None:
            raise RuntimeError(
                "missing iFinD token state; run auth-set-refresh-token with "
                "the refresh_token from iFinD Super Command"
            )

        if not bundle.refresh_token:
            raise RuntimeError(
                "cached iFinD token state has no refresh_token; run "
                "auth-set-refresh-token"
            )

        try:
            refreshed = self.refresh_exchange(bundle.refresh_token)
        except Exception:
            raise RuntimeError(
                "failed to exchange iFinD refresh_token for access_token"
            )
        self.state_store.save(refreshed)
        return refreshed, "refresh"


def exchange_refresh_token(
    refresh_token: str,
    *,
    base_url: str,
    timeout: float = 10.0,
    now: Callable[[], datetime] | None = None,
) -> TokenBundle:
    response = requests.post(
        f"{base_url.rstrip('/')}/get_access_token",
        json={},
        headers={"Content-Type": "application/json", "refresh_token": refresh_token},
        timeout=timeout,
    )
    response.raise_for_status()

    access_token, expires_in = _parse_refresh_payload(response.json())
    return TokenBundle(
        access_token=access_token,
        refresh_token=refresh_token,
        expires_at=_resolve_refresh_expiry(expires_in, now=now),
    )


def _parse_refresh_payload(payload: object) -> tuple[str, int]:
    if not isinstance(payload, dict):
        raise ValueError("iFinD auth response must be a JSON object")

    data = payload.get("data")
    if not isinstance(data, dict):
        raise ValueError("iFinD auth response missing data object")

    access_token = data.get("access_token")
    if not isinstance(access_token, str) or not access_token:
        raise ValueError("iFinD auth response missing access_token")

    expires_in_raw = data.get("expires_in", 0)
    try:
        expires_in = int(expires_in_raw)
    except (TypeError, ValueError):
        expires_in = 0
    return access_token, expires_in


def _resolve_refresh_expiry(
    expires_in: int,
    *,
    now: Callable[[], datetime] | None = None,
) -> str:
    current = now() if now is not None else datetime.now(timezone.utc)
    if current.tzinfo is None:
        current = current.replace(tzinfo=timezone.utc)
    else:
        current = current.astimezone(timezone.utc)
    expires_at = current + timedelta(seconds=max(expires_in - 30, 0))
    return format_timestamp(expires_at)


path: scripts/runtime/tonghuashun_ifind_skill/client.py
content: from __future__ import annotations

from collections.abc import Callable
from datetime import datetime
from datetime import timezone
from typing import Any

from tonghuashun_ifind_skill.endpoint_catalog import get_endpoint_spec
from tonghuashun_ifind_skill.models import ErrorPayload
from tonghuashun_ifind_skill.models import ResponseEnvelope
from tonghuashun_ifind_skill.models import ResponseMeta
from tonghuashun_ifind_skill.models import format_timestamp


class IFindClient:
    def __init__(
        self,
        *,
        base_url: str,
        session: Any | None = None,
        timeout: float = 30.0,
        now: Callable[[], datetime] | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.session = session if session is not None else self._default_session()
        self.timeout = timeout
        self._now = now or (lambda: datetime.now(timezone.utc))

    def api_call(
        self,
        endpoint: str,
        payload: dict[str, object],
        access_token: str,
        token_source: str,
    ) -> dict[str, object]:
        normalized_endpoint = self._normalize_endpoint(endpoint)
        url = f"{self.base_url}{normalized_endpoint}"
        headers = {"Content-Type": "application/json", "access_token": access_token}
        timestamp = format_timestamp(self._now())

        try:
            response = self.session.post(
                url,
                json=payload,
                headers=headers,
                timeout=self.timeout,
            )
        except Exception as exc:
            envelope = ResponseEnvelope(
                ok=False,
                endpoint=normalized_endpoint,
                token_source=token_source,
                data=None,
                error=ErrorPayload(
                    type="runtime_failed",
                    message=self._sanitize_exception_message(exc),
                ),
                meta=ResponseMeta(timestamp=timestamp),
            )
            return envelope.to_dict()

        status_code = getattr(response, "status_code", 200)
        try:
            body = response.json()
        except Exception as exc:
            error_message = str(exc) if status_code < 400 else f"http error {status_code}"
            envelope = ResponseEnvelope(
                ok=False,
                endpoint=normalized_endpoint,
                token_source=token_source,
                data=None,
                error=ErrorPayload(type="runtime_failed", message=error_message),
                meta=ResponseMeta(timestamp=timestamp),
            )
            return envelope.to_dict()

        error = self._extract_error(body)
        if status_code >= 400:
            if error is None:
                error = ErrorPayload(
                    type="runtime_failed",
                    message=f"http error {status_code}",
                )
            envelope = ResponseEnvelope(
                ok=False,
                endpoint=normalized_endpoint,
                token_source=token_source,
                data=None,
                error=error,
                meta=ResponseMeta(timestamp=timestamp),
            )
            return envelope.to_dict()

        envelope = ResponseEnvelope(
            ok=error is None,
            endpoint=normalized_endpoint,
            token_source=token_source,
            data=None if error is not None else body,
            error=error,
            meta=ResponseMeta(timestamp=timestamp),
        )
        return envelope.to_dict()

    def basic_data(
        self,
        payload: dict[str, object],
        access_token: str,
        token_source: str,
    ) -> dict[str, object]:
        return self.api_call("/basic_data_service", payload, access_token, token_source)

    def smart_stock_picking(
        self,
        payload: dict[str, object],
        access_token: str,
        token_source: str,
    ) -> dict[str, object]:
        return self.api_call(
            "/smart_stock_picking",
            payload,
            access_token,
            token_source,
        )

    def report_query(
        self,
        payload: dict[str, object],
        access_token: str,
        token_source: str,
    ) -> dict[str, object]:
        return self.api_call("/report_query", payload, access_token, token_source)

    def date_sequence(
        self,
        payload: dict[str, object],
        access_token: str,
        token_source: str,
    ) -> dict[str, object]:
        return self.api_call(
            "/date_sequence",
            payload,
            access_token,
            token_source,
        )

    def call_named_endpoint(
        self,
        name: str,
        payload: dict[str, object],
        access_token: str,
        token_source: str,
    ) -> dict[str, object]:
        spec = get_endpoint_spec(name)
        return self.api_call(
            spec.endpoint,
            payload,
            access_token,
            token_source,
        )

    @staticmethod
    def _normalize_endpoint(endpoint: str) -> str:
        if not endpoint:
            return "/"
        return endpoint if endpoint.startswith("/") else f"/{endpoint}"

    @staticmethod
    def _default_session() -> Any:
        try:
            import requests
        except ModuleNotFoundError as exc:  # pragma: no cover - runtime dependency guard
            raise RuntimeError("requests is required for default sessions") from exc
        return requests.Session()

    @staticmethod
    def _extract_error(payload: object) -> ErrorPayload | None:
        if not isinstance(payload, dict):
            return None
        if "errorcode" not in payload:
            return None
        errorcode = payload.get("errorcode")
        errmsg = payload.get("errmsg")
        if errorcode in (0, "0", None):
            return None
        message = errmsg if isinstance(errmsg, str) else "iFinD business error"
        return ErrorPayload(
            type="api_failed",
            message=message,
            errorcode=errorcode,
            errmsg=errmsg if isinstance(errmsg, str) else None,
        )

    @staticmethod
    def _sanitize_exception_message(exc: Exception) -> str:
        exc_name = exc.__class__.__name__
        return f"request failed: {exc_name}"


def build_envelope(
    *,
    ok: bool,
    endpoint: str,
    token_source: str,
    data: object | None = None,
    error_type: str | None = None,
    error_message: str | None = None,
    errorcode: int | str | None = None,
    errmsg: str | None = None,
    now: Callable[[], datetime] | datetime | None = None,
) -> dict[str, object]:
    if callable(now):
        timestamp = format_timestamp(now())
    elif isinstance(now, datetime):
        timestamp = format_timestamp(now)
    else:
        timestamp = format_timestamp()

    error = None
    if not ok:
        error = ErrorPayload(
            type=error_type or "runtime_failed",
            message=error_message or "request failed",
            errorcode=errorcode,
            errmsg=errmsg,
        )
    envelope = ResponseEnvelope(
        ok=ok,
        endpoint=endpoint,
        token_source=token_source,
        data=data,
        error=error,
        meta=ResponseMeta(timestamp=timestamp),
    )
    return envelope.to_dict()


path: scripts/runtime/tonghuashun_ifind_skill/endpoint_catalog.py
content: from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class EndpointSpec:
    name: str
    endpoint: str
    category: str
    description: str
    example_payload: dict[str, object]
    requires_ifind_auth: bool = True
    notes: tuple[str, ...] = ()

    def to_dict(self) -> dict[str, object]:
        return {
            "name": self.name,
            "endpoint": self.endpoint,
            "category": self.category,
            "description": self.description,
            "example_payload": self.example_payload,
            "requires_ifind_auth": self.requires_ifind_auth,
            "notes": list(self.notes),
        }


_ENDPOINT_SPECS = {
    "basic_data": EndpointSpec(
        name="basic_data",
        endpoint="/basic_data_service",
        category="core_api",
        description="基础指标查询，适合直接按代码和指标名取数。",
        example_payload={
            "codes": "300750.SZ",
            "indicators": "ths_close_price_stock",
        },
    ),
    "smart_pick": EndpointSpec(
        name="smart_pick",
        endpoint="/smart_stock_picking",
        category="core_api",
        description="自然语言选股与金融问答透传接口，涨停、榜单、画像、资金流都基于它。",
        example_payload={
            "searchstring": "今天的A股涨停数据",
            "searchtype": "stock",
        },
        notes=(
            "所有数据来自 iFinD；使用前必须完成鉴权。",
            "涨停、榜单、画像和资金流都通过 iFinD smart_stock_picking 透传。",
        ),
    ),
    "report_query": EndpointSpec(
        name="report_query",
        endpoint="/report_query",
        category="core_api",
        description="研报或报告类查询透传接口。",
        example_payload={"codes": "300750.SZ"},
    ),
    "date_sequence": EndpointSpec(
        name="date_sequence",
        endpoint="/date_sequence",
        category="core_api",
        description="日期序列、交易日历等时间轴能力透传接口。",
        example_payload={
            "codes": "000001.SH",
            "startdate": "2026-04-01",
            "enddate": "2026-04-30",
            "functionpara": {"Days": "Tradedays", "Fill": "Omit"},
            "indipara": [
                {
                    "indicator": "ths_close_price_stock",
                    "indiparams": ["", "100", ""],
                }
            ],
        },
    ),
    "real_time_quote": EndpointSpec(
        name="real_time_quote",
        endpoint="/real_time_quotation",
        category="market_data",
        description="实时行情原始接口，适合单股或指数快照。",
        example_payload={
            "codes": "600519.SH,000300.SH",
            "indicators": (
                "open,high,low,latest,changeRatio,change,preClose,volume,"
                "amount,turnoverRatio,volumeRatio,amplitude,pb"
            ),
        },
        notes=("所有行情数据来自 iFinD；不再回退到公开行情源。",),
    ),
    "history_quote": EndpointSpec(
        name="history_quote",
        endpoint="/cmd_history_quotation",
        category="market_data",
        description="历史行情原始接口，适合日线区间查询。",
        example_payload={
            "codes": "600004.SH",
            "indicators": "open,close,high,low,volume",
            "startdate": "2026-04-21",
            "enddate": "2026-04-21",
        },
        notes=("所有历史行情数据来自 iFinD；不再回退到公开行情源。",),
    ),
    "limit_up_screen": EndpointSpec(
        name="limit_up_screen",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="涨停池能力别名；推荐优先用 smart-query。",
        example_payload={
            "searchstring": "今天的A股涨停数据",
            "searchtype": "stock",
        },
        notes=("所有涨停数据来自 iFinD smart_stock_picking。",),
    ),
    "leaderboard_screen": EndpointSpec(
        name="leaderboard_screen",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="榜单能力别名；适合成交额榜、涨跌幅榜、换手率榜、振幅榜、量比榜。",
        example_payload={
            "searchstring": "A股成交额榜前十",
            "searchtype": "stock",
        },
        notes=("所有榜单数据来自 iFinD smart_stock_picking。",),
    ),
    "fundamental_basic": EndpointSpec(
        name="fundamental_basic",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="基本面能力别名；推荐优先用 smart-query 或 fundamental-basic。",
        example_payload={
            "searchstring": "宁德时代基本面",
            "searchtype": "stock",
        },
        notes=("所有基本面数据来自 iFinD smart_stock_picking。",),
    ),
    "entity_profile": EndpointSpec(
        name="entity_profile",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="公司简介、主营业务等画像能力别名。",
        example_payload={
            "searchstring": "贵州茅台主营业务是什么",
            "searchtype": "stock",
        },
        notes=("所有画像数据来自 iFinD smart_stock_picking。",),
    ),
    "capital_flow": EndpointSpec(
        name="capital_flow",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="资金流问法能力别名。",
        example_payload={
            "searchstring": "今天主力资金流入前十",
            "searchtype": "stock",
        },
        notes=("所有资金流数据来自 iFinD smart_stock_picking。",),
    ),
    "a_share_common_query": EndpointSpec(
        name="a_share_common_query",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="A 股用户常见自然语言查询入口；覆盖公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股和交易日等问法。",
        example_payload={
            "searchstring": "贵州茅台最近分红、十大股东和北向持股情况",
            "searchtype": "stock",
        },
        notes=(
            "这些问法保留用户原始自然语言交给 iFinD smart_stock_picking。",
            "如果 iFinD 返回权限不足或无法处理，直接反馈 iFinD 失败，不切换公开源。",
        ),
    ),
    "generic_smart_query": EndpointSpec(
        name="generic_smart_query",
        endpoint="/smart_stock_picking",
        category="routed_capability",
        description="自然语言泛化查询入口；当本地规则没有稳定命中特定能力时，将用户原问题交给 iFinD smart_stock_picking。",
        example_payload={
            "searchstring": "筛一下新能源车产业链里市盈率低于30且近一个月放量的股票",
            "searchtype": "stock",
        },
        notes=("这是 smart-query 的自然语言兜底入口，但数据仍然只来自 iFinD。",),
    ),
}


def list_endpoint_specs() -> list[EndpointSpec]:
    return [
        _ENDPOINT_SPECS[name]
        for name in sorted(_ENDPOINT_SPECS.keys())
    ]


def get_endpoint_spec(name: str) -> EndpointSpec:
    normalized_name = name.strip().lower()
    try:
        return _ENDPOINT_SPECS[normalized_name]
    except KeyError as exc:
        supported = ", ".join(sorted(_ENDPOINT_SPECS.keys()))
        raise ValueError(
            f"unknown endpoint name: {name}. supported names: {supported}"
        ) from exc


path: scripts/runtime/tonghuashun_ifind_skill/llm_routing.py
content: from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import replace
from datetime import date
import json
import os
from typing import Any

from tonghuashun_ifind_skill.routing import ResolvedEntity
from tonghuashun_ifind_skill.routing import RoutePlan
from tonghuashun_ifind_skill.routing import build_capital_flow_plan
from tonghuashun_ifind_skill.routing import build_entity_profile_plan
from tonghuashun_ifind_skill.routing import build_fundamental_plan
from tonghuashun_ifind_skill.routing import build_generic_smart_query_plan
from tonghuashun_ifind_skill.routing import build_history_plan
from tonghuashun_ifind_skill.routing import build_leaderboard_plan
from tonghuashun_ifind_skill.routing import build_limit_up_plan
from tonghuashun_ifind_skill.routing import build_market_snapshot_plan
from tonghuashun_ifind_skill.routing import build_realtime_plan
from tonghuashun_ifind_skill.routing import build_trading_calendar_plan
from tonghuashun_ifind_skill.routing import normalize_symbol


_SUPPORTED_INTENTS = {
    "quote_realtime",
    "quote_history",
    "market_snapshot",
    "fundamental_basic",
    "limit_up_screen",
    "leaderboard_screen",
    "entity_profile",
    "capital_flow",
    "trading_calendar",
    "generic_smart_query",
    "manual_lookup",
}
_ENTITY_INTENTS = {
    "quote_realtime",
    "quote_history",
    "fundamental_basic",
    "entity_profile",
}
_TRUTHY = {"1", "true", "yes", "on"}


@dataclass(frozen=True)
class LLMRoutingConfig:
    api_key: str
    model: str
    base_url: str = "https://api.openai.com/v1"
    timeout: float = 12.0
    min_confidence: float = 0.65

    @classmethod
    def from_env(cls) -> "LLMRoutingConfig | None":
        enabled_raw = os.environ.get("IFIND_ROUTE_LLM_ENABLED", "").strip().lower()
        api_key = (
            os.environ.get("IFIND_ROUTE_LLM_API_KEY")
            or os.environ.get("OPENAI_API_KEY")
            or ""
        ).strip()
        if enabled_raw not in _TRUTHY:
            return None
        if not [REDACTED_SECRET] None

        model = os.environ.get("IFIND_ROUTE_LLM_MODEL", "gpt-4o-mini").strip()
        base_url = os.environ.get("IFIND_ROUTE_LLM_BASE_URL", cls.base_url).strip()
        timeout = _float_from_env("IFIND_ROUTE_LLM_TIMEOUT", cls.timeout)
        min_confidence = _float_from_env(
            "IFIND_ROUTE_LLM_MIN_CONFIDENCE",
            cls.min_confidence,
        )
        return cls(
            [REDACTED_SECRET],
            model=model,
            base_url=base_url.rstrip("/"),
            timeout=timeout,
            min_confidence=min_confidence,
        )


def build_llm_route_plan(
    query: str,
    *,
    entity_lookup: Callable[[str], ResolvedEntity | None],
    today: date | None = None,
    session: Any | None = None,
    config: LLMRoutingConfig | None = None,
) -> RoutePlan | None:
    effective_config = config or LLMRoutingConfig.from_env()
    if effective_config is None:
        return None

    response_payload = _call_router_model(
        query=query,
        today=today or date.today(),
        session=session,
        config=effective_config,
    )
    if response_payload is None:
        return None
    return _route_json_to_plan(
        response_payload,
        query=query,
        today=today or date.today(),
        entity_lookup=entity_lookup,
        min_confidence=effective_config.min_confidence,
    )


def _call_router_model(
    *,
    query: str,
    today: date,
    session: Any | None,
    config: LLMRoutingConfig,
) -> dict[str, object] | None:
    client = session if session is not None else _default_session()
    payload = {
        "model": config.model,
        "temperature": 0,
        "response_format": {"type": "json_object"},
        "messages": [
            {
                "role": "system",
                "content": (
                    "你是 tonghuashun-ifind-skill skill 的自然语言路由器。"
                    "只返回 JSON 对象，不要返回解释。所有数据查询都必须走同花顺 iFinD，"
                    "不得规划腾讯、东方财富或其它公开源。"
                ),
            },
            {
                "role": "user",
                "content": _router_prompt(query=query, today=today),
            },
        ],
    }
    try:
        response = client.post(
            f"{config.base_url}/chat/completions",
            json=payload,
            headers={"Authorization": f"Bearer {config.api_key}"},
            timeout=config.timeout,
        )
        response.raise_for_status()
        raw = response.json()
    except Exception:
        return None

    content = _extract_chat_content(raw)
    if content is None:
        return None
    try:
        parsed = json.loads(content)
    except json.JSONDecodeError:
        return None
    return parsed if isinstance(parsed, dict) else None


def _router_prompt(*, query: str, today: date) -> str:
    return json.dumps(
        {
            "today": today.isoformat(),
            "query": query,
            "allowed_intents": sorted(_SUPPORTED_INTENTS),
            "output_schema": {
                "intent": "one allowed intent",
                "confidence": "0.0-1.0",
                "entity_text": "股票/指数名称，可为空",
                "symbol": "标准代码如 600519.SH，可为空",
                "entity_type": "stock 或 index，可为空",
                "start_date": "YYYY-MM-DD，仅 quote_history 需要",
                "end_date": "YYYY-MM-DD，仅 quote_history 需要",
                "searchstring": "传给 smart_stock_picking 的原始自然语言，可为空",
                "note": "manual_lookup 或低置信度原因，可为空",
            },
            "rules": [
                "行情、历史、大盘、基本面、涨停、榜单、画像、资金流都优先映射到 iFinD endpoint。",
                "公告、研报、龙虎榜、两融、北向、股东、持仓、分红、解禁、停复牌、概念板块、新股和交易日等 A 股常见问法，优先输出 generic_smart_query，并把用户原话放入 searchstring。",
                "休市、开不开盘、下一个交易日等交易日历问法，优先输出 trading_calendar。",
                "quote_history 没有日期时按最近 30 天。",
                "不要输出公开数据源、fallback_type、provider 或非 iFinD 字段。",
                "无法稳定判断时输出 manual_lookup。",
            ],
        },
        ensure_ascii=False,
    )


def _route_json_to_plan(
    payload: dict[str, object],
    *,
    query: str,
    today: date,
    entity_lookup: Callable[[str], ResolvedEntity | None],
    min_confidence: float,
) -> RoutePlan | None:
    intent = _text(payload.get("intent"))
    if intent not in _SUPPORTED_INTENTS:
        return None

    confidence = _float(payload.get("confidence"))
    if confidence is not None and confidence < min_confidence:
        return None

    if intent == "manual_lookup":
        return RoutePlan(
            intent="manual_lookup",
            endpoint=None,
            payload=None,
            entity=None,
            note=_text(payload.get("note"))
            or "大模型路由无法稳定映射该请求，请先查看 references/routing.md。",
        )

    searchstring = _text(payload.get("searchstring")) or query
    entity = None
    if intent in _ENTITY_INTENTS:
        entity = _entity_from_payload(payload)
        if entity is None:
            entity_hint = _text(payload.get("entity_text")) or query
            entity = entity_lookup(entity_hint)
        if entity is None:
            return None

    if intent == "quote_realtime":
        return _with_llm_note(build_realtime_plan(entity))
    if intent == "quote_history":
        return _with_llm_note(
            build_history_plan(
                entity,
                query=query,
                today=today,
                start_date=_text(payload.get("start_date")),
                end_date=_text(payload.get("end_date")),
            )
        )
    if intent == "fundamental_basic":
        return _with_llm_note(build_fundamental_plan(entity))
    if intent == "entity_profile":
        return _with_llm_note(build_entity_profile_plan(entity, searchstring))
    if intent == "market_snapshot":
        market_query = _text(payload.get("symbol")) or _text(payload.get("entity_text")) or query
        return _with_llm_note(build_market_snapshot_plan(market_query))
    if intent == "limit_up_screen":
        return _with_llm_note(build_limit_up_plan(searchstring))
    if intent == "leaderboard_screen":
        return _with_llm_note(build_leaderboard_plan(searchstring))
    if intent == "capital_flow":
        return _with_llm_note(build_capital_flow_plan(searchstring))
    if intent == "trading_calendar":
        return _with_llm_note(build_trading_calendar_plan(searchstring, today=today))
    if intent == "generic_smart_query":
        return _with_llm_note(build_generic_smart_query_plan(searchstring, entity=entity))
    return None


def _entity_from_payload(payload: dict[str, object]) -> ResolvedEntity | None:
    symbol = _text(payload.get("symbol"))
    if not symbol:
        return None
    normalized_symbol = normalize_symbol(symbol)
    entity_type = _text(payload.get("entity_type"))
    if entity_type not in {"stock", "index"}:
        entity_type = "index" if normalized_symbol in {
            "000001.SH",
            "399001.SZ",
            "399006.SZ",
            "000300.SH",
        } else "stock"
    return ResolvedEntity(
        raw=_text(payload.get("entity_text")) or normalized_symbol,
        symbol=normalized_symbol,
        name=_text(payload.get("entity_text")),
        entity_type=entity_type,
    )


def _with_llm_note(plan: RoutePlan) -> RoutePlan:
    note = "route_source=llm"
    if plan.note:
        note = f"{note}; {plan.note}"
    return replace(plan, note=note)


def _extract_chat_content(payload: object) -> str | None:
    if not isinstance(payload, dict):
        return None
    choices = payload.get("choices")
    if not isinstance(choices, list) or not choices:
        return None
    first = choices[0]
    if not isinstance(first, dict):
        return None
    message = first.get("message")
    if not isinstance(message, dict):
        return None
    content = message.get("content")
    return content if isinstance(content, str) and content.strip() else None


def _default_session() -> Any:
    try:
        import requests
    except ModuleNotFoundError as exc:  # pragma: no cover
        raise RuntimeError("requests is required for LLM routing") from exc
    return requests.Session()


def _text(value: object) -> str | None:
    if isinstance(value, str) and value.strip():
        return value.strip()
    return None


def _float(value: object) -> float | None:
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        try:
            return float(value.strip())
        except ValueError:
            return None
    return None


def _float_from_env(name: str, default: float) -> float:
    value = os.environ.get(name)
    parsed = _float(value)
    return default if parsed is None else parsed


path: scripts/runtime/tonghuashun_ifind_skill/models.py
content: from __future__ import annotations

from dataclasses import asdict
from dataclasses import dataclass
from datetime import datetime
from datetime import timezone
from typing import Any


@dataclass
class TokenBundle:
    access_token: str
    refresh_token: str
    expires_at: str | None = None

    def to_dict(self) -> dict[str, str | None]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: Any) -> "TokenBundle":
        if not isinstance(data, dict):
            raise ValueError("token state must be a JSON object")

        access_token = cls._require_text(data, "access_token")
        refresh_token = cls._require_text(data, "refresh_token")
        expires_at = data.get("expires_at")

        if expires_at is not None and not isinstance(expires_at, str):
            raise ValueError("expires_at must be a string or null")

        return cls(
            access_token=access_token,
            refresh_token=refresh_token,
            expires_at=expires_at,
        )

    def is_stale(self, *, now: datetime | None = None) -> bool:
        if self.expires_at is None:
            return True
        current_time = self._normalize_datetime(now or datetime.now(timezone.utc))
        try:
            return current_time >= self.expires_at_datetime
        except ValueError:
            return True

    @property
    def expires_at_datetime(self) -> datetime:
        if self.expires_at is None:
            raise ValueError("expires_at is not set")
        try:
            parsed = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
        except ValueError as exc:
            raise ValueError("expires_at must be a valid ISO 8601 datetime") from exc
        return self._normalize_datetime(parsed)

    @staticmethod
    def _normalize_datetime(value: datetime) -> datetime:
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value.astimezone(timezone.utc)

    @staticmethod
    def _require_text(data: dict[str, Any], key: str) -> str:
        value = data.get(key)
        if not isinstance(value, str):
            raise ValueError(f"{key} must be a string")
        return value


@dataclass
class ErrorPayload:
    type: str
    message: str
    errorcode: int | str | None = None
    errmsg: str | None = None

    def to_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "type": self.type,
            "message": self.message,
        }
        if self.errorcode is not None:
            payload["errorcode"] = self.errorcode
        if self.errmsg is not None:
            payload["errmsg"] = self.errmsg
        return payload


@dataclass
class ResponseMeta:
    timestamp: str

    def to_dict(self) -> dict[str, str]:
        return {"timestamp": self.timestamp}


@dataclass
class ResponseEnvelope:
    ok: bool
    endpoint: str
    token_source: str
    data: object | None
    error: ErrorPayload | None
    meta: ResponseMeta

    def to_dict(self) -> dict[str, object]:
        return {
            "ok": self.ok,
            "endpoint": self.endpoint,
            "token_source": self.token_source,
            "data": self.data,
            "error": None if self.error is None else self.error.to_dict(),
            "meta": self.meta.to_dict(),
        }


def format_timestamp(now: datetime | None = None) -> str:
    current = now or datetime.now(timezone.utc)
    if current.tzinfo is None:
        current = current.replace(tzinfo=timezone.utc)
    else:
        current = current.astimezone(timezone.utc)
    current = current.replace(microsecond=0)
    return current.isoformat().replace("+00:00", "Z")


path: scripts/runtime/tonghuashun_ifind_skill/routing.py
content: from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from datetime import timedelta
import re
from typing import Callable
from typing import Literal


Intent = Literal[
    "quote_realtime",
    "quote_history",
    "market_snapshot",
    "fundamental_basic",
    "limit_up_screen",
    "leaderboard_screen",
    "entity_profile",
    "capital_flow",
    "trading_calendar",
    "generic_smart_query",
    "manual_lookup",
]
EntityType = Literal["stock", "index"]


HISTORY_INDICATORS = "open,high,low,close,volume,amount,changeRatio"
REALTIME_INDICATORS = (
    "open,high,low,latest,changeRatio,change,preClose,"
    "volume,amount,turnoverRatio,volumeRatio,amplitude,pb"
)
MARKET_SNAPSHOT_INDICATORS = "open,high,low,latest,changeRatio,change,preClose,volume,amount"

FINANCIAL_QUERY_TEMPLATE = (
    "{target} 营业总收入 归属于母公司所有者的净利润 扣除非经常性损益后的净利润 "
    "销售毛利率 销售净利率 净资产收益率roe 资产负债率 经营活动产生的现金流量净额 存货"
)
VALUATION_QUERY_TEMPLATE = "{target} 量比 换手率 市盈率 市净率 总市值 流通市值"
FORECAST_QUERY_TEMPLATE = "{target} 预测净利润平均值 预测主营业务收入平均值 2026 2027"

DEFAULT_MARKET_ENTITIES = (
    ("上证指数", "000001.SH"),
    ("深证成指", "399001.SZ"),
    ("创业板指", "399006.SZ"),
    ("沪深300", "000300.SH"),
)

KNOWN_INDEX_ENTITIES = DEFAULT_MARKET_ENTITIES + (
    ("上证50", "000016.SH"),
    ("科创50", "000688.SH"),
    ("中证500", "000905.SH"),
    ("中证1000", "000852.SH"),
    ("北证50", "899050.BJ"),
)

POPULAR_STOCK_ALIASES = {
    "茅台": ("贵州茅台", "600519.SH"),
    "茅子": ("贵州茅台", "600519.SH"),
    "宁王": ("宁德时代", "300750.SZ"),
    "宁德": ("宁德时代", "300750.SZ"),
    "catl": ("宁德时代", "300750.SZ"),
    "迪王": ("比亚迪", "002594.SZ"),
    "比亚迪": ("比亚迪", "002594.SZ"),
    "byd": ("比亚迪", "002594.SZ"),
    "招行": ("招商银行", "600036.SH"),
    "平安银行": ("平安银行", "000001.SZ"),
    "中国平安": ("中国平安", "601318.SH"),
    "平安": ("中国平安", "601318.SH"),
    "中芯国际": ("中芯国际", "688981.SH"),
    "中芯": ("中芯国际", "688981.SH"),
    "迈瑞医疗": ("迈瑞医疗", "300760.SZ"),
    "迈瑞": ("迈瑞医疗", "300760.SZ"),
    "药明康德": ("药明康德", "603259.SH"),
    "药明": ("药明康德", "603259.SH"),
    "东财": ("东方财富", "300059.SZ"),
    "工行": ("工商银行", "601398.SH"),
    "建行": ("建设银行", "601939.SH"),
    "农行": ("农业银行", "601288.SH"),
    "中行": ("中国银行", "601988.SH"),
    "五粮液": ("五粮液", "000858.SZ"),
    "隆基": ("隆基绿能", "601012.SH"),
    "隆基绿能": ("隆基绿能", "601012.SH"),
    "格力": ("格力电器", "000651.SZ"),
    "格力电器": ("格力电器", "000651.SZ"),
    "美的": ("美的集团", "000333.SZ"),
    "美的集团": ("美的集团", "000333.SZ"),
    "万科": ("万科A", "000002.SZ"),
    "万科a": ("万科A", "000002.SZ"),
    "长电": ("长江电力", "600900.SH"),
    "长江电力": ("长江电力", "600900.SH"),
    "紫金": ("紫金矿业", "601899.SH"),
    "紫金矿业": ("紫金矿业", "601899.SH"),
    "中信证券": ("中信证券", "600030.SH"),
    "海康": ("海康威视", "002415.SZ"),
    "海康威视": ("海康威视", "002415.SZ"),
    "伊利": ("伊利股份", "600887.SH"),
    "伊利股份": ("伊利股份", "600887.SH"),
    "牧原": ("牧原股份", "002714.SZ"),
    "牧原股份": ("牧原股份", "002714.SZ"),
    "立讯": ("立讯精密", "002475.SZ"),
    "立讯精密": ("立讯精密", "002475.SZ"),
    "恒瑞": ("恒瑞医药", "600276.SH"),
    "恒瑞医药": ("恒瑞医药", "600276.SH"),
    "万华": ("万华化学", "600309.SH"),
    "万华化学": ("万华化学", "600309.SH"),
    "三一": ("三一重工", "600031.SH"),
    "三一重工": ("三一重工", "600031.SH"),
    "海尔": ("海尔智家", "600690.SH"),
    "海尔智家": ("海尔智家", "600690.SH"),
    "中兴": ("中兴通讯", "000063.SZ"),
    "中兴通讯": ("中兴通讯", "000063.SZ"),
    "京东方": ("京东方A", "000725.SZ"),
    "京东方a": ("京东方A", "000725.SZ"),
    "tcl科技": ("TCL科技", "000100.SZ"),
    "寒武纪": ("寒武纪", "688256.SH"),
    "中际旭创": ("中际旭创", "300308.SZ"),
    "新易盛": ("新易盛", "300502.SZ"),
}

INDEX_ALIASES = {
    "上证指数": ("上证指数", "000001.SH"),
    "上证综指": ("上证指数", "000001.SH"),
    "上证": ("上证指数", "000001.SH"),
    "沪指": ("上证指数", "000001.SH"),
    "深证成指": ("深证成指", "399001.SZ"),
    "深成指": ("深证成指", "399001.SZ"),
    "深指": ("深证成指", "399001.SZ"),
    "创业板指": ("创业板指", "399006.SZ"),
    "创业板指数": ("创业板指", "399006.SZ"),
    "创业板": ("创业板指", "399006.SZ"),
    "沪深300": ("沪深300", "000300.SH"),
    "hs300": ("沪深300", "000300.SH"),
    "上证50": ("上证50", "000016.SH"),
    "sz50": ("上证50", "000016.SH"),
    "科创50": ("科创50", "000688.SH"),
    "科创板50": ("科创50", "000688.SH"),
    "中证500": ("中证500", "000905.SH"),
    "zz500": ("中证500", "000905.SH"),
    "中证1000": ("中证1000", "000852.SH"),
    "zz1000": ("中证1000", "000852.SH"),
    "北证50": ("北证50", "899050.BJ"),
    "000001.sh": ("上证指数", "000001.SH"),
    "399001.sz": ("深证成指", "399001.SZ"),
    "399006.sz": ("创业板指", "399006.SZ"),
    "000300.sh": ("沪深300", "000300.SH"),
    "000016.sh": ("上证50", "000016.SH"),
    "000688.sh": ("科创50", "000688.SH"),
    "000905.sh": ("中证500", "000905.SH"),
    "000852.sh": ("中证1000", "000852.SH"),
    "899050.bj": ("北证50", "899050.BJ"),
}

_ETF_SH_PREFIXES = ("50", "51", "52", "56", "58")
_ETF_SZ_PREFIXES = ("15", "16", "18")
_SYMBOL_RE = re.compile(
    r"(?i)(?<![A-Z0-9.])(?:SH|SZ|BJ)?\d{6}(?:\.(?:SH|SZ|BJ)|(?:SH|SZ|BJ))?(?![A-Z0-9.])"
)
_DATE_RANGE_RE = re.compile(r"(?P<start>\d{4}-\d{2}-\d{2}).*?(?P<end>\d{4}-\d{2}-\d{2})")
_SINGLE_DATE_RE = re.compile(r"(?P<date>\d{4}-\d{2}-\d{2})")
_MONTH_DAY_RE = re.compile(r"(?P<month>\d{1,2})月(?P<day>\d{1,2})[日号]?")
_MANUAL_LOOKUP_PATTERNS = ("pdf", "下载链接", "全文下载", "原文")
_FUNDAMENTAL_PATTERNS = (
    "基本面",
    "财务",
    "估值",
    "营收",
    "净利润",
    "roe",
    "市盈率",
    "市净率",
    "pe",
    "pb",
    "市值",
)
_PRECISE_FINANCIAL_METRIC_PATTERNS = (
    "营收",
    "营业收入",
    "营业总收入",
    "收入",
    "毛利率",
    "净利率",
    "净利润",
    "扣非",
    "roe",
    "资产负债率",
    "现金流",
    "市盈率",
    "市净率",
    "pe",
    "pb",
    "市值",
    "总市值",
    "流通市值",
    "同比",
    "环比",
)
_HISTORY_PATTERNS = (
    "历史",
    "走势",
    "k线",
    "日k",
    "周k",
    "月k",
    "近一个月",
    "最近一个月",
    "近1个月",
    "近一周",
    "最近一周",
    "近1周",
    "近三个月",
    "最近三个月",
    "近3个月",
    "近半年",
    "近一年",
)
_MARKET_PATTERNS = (
    "大盘",
    "盘面",
    "市场表现",
    "市场快照",
    "指数",
    "a股",
    "沪指",
    "深指",
    "创业板",
)
_LIMIT_UP_PATTERNS = ("涨停", "涨停板", "封板")
_LEADERBOARD_PATTERNS = ("榜", "排行", "排名", "top", "前十", "前二十", "前30", "前50")
_PROFILE_PATTERNS = (
    "主营业务",
    "公司简介",
    "公司介绍",
    "干啥",
    "干什么",
    "做什么",
    "是做什么的",
    "业务是什么",
    "属于什么行业",
)
_TRADING_CALENDAR_PATTERNS = (
    "交易日",
    "休市",
    "开不开盘",
    "是否开盘",
    "是否交易",
)
_CAPITAL_FLOW_PATTERNS = (
    "资金流",
    "主力资金",
    "资金净流入",
    "资金净流出",
    "净流入",
    "净流出",
)
_COMMON_A_SHARE_SMART_PATTERNS = (
    "公告",
    "业绩预告",
    "业绩快报",
    "一季度报告",
    "三季度报告",
    "年度报告",
    "半年报",
    "一季报",
    "三季报",
    "年报",
    "季报",
    "报告",
    "摘要",
    "研报",
    "评级",
    "目标价",
    "消息",
    "新闻",
    "资讯",
    "舆情",
    "龙虎榜",
    "大宗交易",
    "异动",
    "融资融券",
    "融资余额",
    "融券",
    "两融",
    "资金",
    "资金往",
    "主力",
    "外资",
    "北向",
    "沪股通",
    "深股通",
    "陆股通",
    "持股",
    "持仓",
    "股东",
    "十大股东",
    "流通股东",
    "机构持仓",
    "基金持仓",
    "分红",
    "派息",
    "送转",
    "除权",
    "除息",
    "股息率",
    "解禁",
    "限售",
    "停牌",
    "复牌",
    "跌停",
    "风险警示",
    "退市",
    "所属概念",
    "概念",
    "题材",
    "产业链",
    "板块",
    "半导体",
    "新能源",
    "白酒",
    "低估值",
    "便宜",
    "利好",
    "机构",
    "龙头",
    "新股",
    "上市公司",
    "申购",
    "中签",
    "打新",
    "发行价",
    "上市日期",
    "交易日",
    "休市",
)
_PRICE_FIELD_PATTERNS = (
    "开盘价",
    "收盘价",
    "最高价",
    "最低价",
    "成交量",
    "成交额",
    "量比",
)
_QUERY_NOISE_PATTERNS = (
    r"看看",
    r"看下",
    r"看一下",
    r"查下",
    r"查一下",
    r"请问",
    r"麻烦",
    r"帮我",
    r"给我",
    r"我想",
    r"想看",
    r"^看",
    r"^查",
    r"能不能",
    r"能否",
    r"有没有",
    r"有没有相关",
    r"相关",
    r"现在",
    r"目前",
    r"今天",
    r"今日",
    r"昨天",
    r"昨日",
    r"明天",
    r"最近",
    r"近期",
    r"今年",
    r"去年",
    r"下一个",
    r"上一个",
    r"时候",
    r"日期",
    r"最新价",
    r"现价",
    r"股价",
    r"股票",
    r"个股",
    r"行情",
    r"报价",
    r"表现",
    r"怎么样",
    r"咋样",
    r"如何",
    r"还行不",
    r"还能看",
    r"能看",
    r"情况",
    r"记录",
    r"数据",
    r"变化",
    r"安排",
    r"多少",
    r"多少[?？]?",
    r"多少钱",
    r"钱了",
    r"吗",
    r"呢",
    r"吧",
    r"有吗",
    r"有啥",
    r"啥消息",
    r"啥研报",
    r"涨没涨",
    r"涨了没",
    r"涨了吗",
    r"红没红",
    r"红了没",
    r"红了吗",
    r"红了",
    r"绿没绿",
    r"绿了吗",
    r"绿了",
    r"跌没跌",
    r"跌了没",
    r"跌了吗",
    r"跌得多",
    r"跌多",
    r"一下",
    r"一下子",
    r"走势",
    r"历史",
    r"k线",
    r"日k",
    r"周k",
    r"月k",
    r"基本面",
    r"财务",
    r"估值",
    r"开盘价",
    r"收盘价",
    r"最高价",
    r"最低价",
    r"成交量",
    r"成交额",
    r"量比",
    r"换手率",
    r"振幅",
    r"涨跌幅",
    r"涨幅",
    r"跌幅",
    r"价格",
    r"市价",
    r"营业总收入",
    r"营业收入",
    r"营收",
    r"收入",
    r"归母净利润",
    r"扣非净利润",
    r"净利润",
    r"毛利率",
    r"净利率",
    r"资产负债率",
    r"现金流",
    r"总市值",
    r"流通市值",
    r"市盈率",
    r"市净率",
    r"动态pe",
    r"静态pe",
    r"滚动pe",
    r"pe",
    r"pb",
    r"roe",
    r"大盘",
    r"指数",
    r"近一个月",
    r"最近一个月",
    r"近1个月",
    r"近一周",
    r"最近一周",
    r"近1周",
    r"近三个月",
    r"最近三个月",
    r"近3个月",
    r"近半年",
    r"近一年",
    r"近\d+天",
    r"主营业务是什么",
    r"主营业务",
    r"业务是什么",
    r"公司简介",
    r"公司介绍",
    r"干啥的",
    r"干啥",
    r"干什么",
    r"做什么的",
    r"是做什么的",
    r"属于什么行业",
    r"什么行业",
    r"是什么",
    r"公告",
    r"业绩预告",
    r"业绩快报",
    r"一季度报告",
    r"三季度报告",
    r"年度报告",
    r"半年报",
    r"一季报",
    r"三季报",
    r"年报",
    r"季报",
    r"报告",
    r"摘要",
    r"研报",
    r"评级",
    r"目标价",
    r"新闻",
    r"资讯",
    r"舆情",
    r"龙虎榜",
    r"大宗交易",
    r"异动",
    r"融资融券",
    r"融资余额",
    r"融券",
    r"两融",
    r"北向资金",
    r"北向",
    r"沪股通",
    r"深股通",
    r"陆股通",
    r"十大流通股东",
    r"十大股东",
    r"流通股东",
    r"机构持仓",
    r"基金持仓",
    r"持股",
    r"持仓",
    r"股东",
    r"分红",
    r"派息",
    r"送转",
    r"除权",
    r"除息",
    r"股息率",
    r"时间表",
    r"比例",
    r"解禁",
    r"限售",
    r"停牌",
    r"复牌",
    r"风险警示",
    r"退市",
    r"所属概念",
    r"概念",
    r"题材",
    r"产业链",
    r"板块",
    r"新股",
    r"申购",
    r"中签",
    r"打新",
    r"发行价",
    r"上市日期",
    r"交易日",
    r"休市",
    r"开不开盘",
)


@dataclass(frozen=True)
class ResolvedEntity:
    raw: str
    symbol: str
    name: str | None
    entity_type: EntityType


@dataclass(frozen=True)
class RoutePlan:
    intent: Intent
    endpoint: str | None
    payload: dict[str, object] | None
    entity: ResolvedEntity | None
    note: str | None = None


def build_route_plan(
    query: str,
    *,
    entity_lookup: Callable[[str], ResolvedEntity | None],
    today: date | None = None,
) -> RoutePlan:
    normalized_query = (query or "").strip()
    effective_today = today or date.today()

    if _is_blank_or_punctuation_only(normalized_query):
        return _manual_lookup_plan(
            "请输入明确的股票、指数、指标、日期或筛选条件；空白或纯标点无法路由到 iFinD。",
        )

    if _needs_manual_lookup(normalized_query):
        return _manual_lookup_plan(
            "这个请求不在内置常见路由里。请先阅读 references/routing.md，再决定是否用 api-call；如果文档里也没有合适接口，就明确告诉用户当前 skill 未覆盖该 iFinD 能力。",
        )

    intent = _detect_intent(normalized_query)
    if intent == "limit_up_screen":
        return build_limit_up_plan(normalized_query)
    if intent == "leaderboard_screen":
        return build_leaderboard_plan(normalized_query)
    if intent == "capital_flow":
        return build_capital_flow_plan(normalized_query)
    if intent == "trading_calendar":
        return build_trading_calendar_plan(normalized_query, today=effective_today)
    if intent == "market_snapshot":
        return build_market_snapshot_plan(normalized_query)

    entity = _resolve_entity(normalized_query, entity_lookup)
    if entity is None:
        if intent == "quote_realtime" and not _has_generic_query_signal(normalized_query):
            return _manual_lookup_plan(
                "请输入明确的股票、指数、指标、日期或筛选条件；当前问题过于笼统，无法稳定路由到 iFinD。",
            )
        return build_generic_smart_query_plan(normalized_query)

    if intent == "generic_smart_query":
        return build_generic_smart_query_plan(normalized_query, entity=entity)
    if intent == "quote_history":
        return build_history_plan(entity, query=normalized_query, today=effective_today)
    if intent == "fundamental_basic":
        if _is_precise_financial_query(normalized_query):
            return build_generic_smart_query_plan(normalized_query, entity=entity)
        return build_fundamental_plan(entity)
    if intent == "entity_profile":
        return build_entity_profile_plan(entity, normalized_query)
    if entity.entity_type == "index":
        return build_market_snapshot_plan(normalized_query, entity=entity)
    return build_realtime_plan(entity)


def build_realtime_plan(entity: ResolvedEntity) -> RoutePlan:
    indicators = (
        MARKET_SNAPSHOT_INDICATORS
        if entity.entity_type == "index"
        else REALTIME_INDICATORS
    )
    return RoutePlan(
        intent="quote_realtime",
        endpoint="/real_time_quotation",
        payload={"codes": entity.symbol, "indicators": indicators},
        entity=entity,
    )


def build_history_plan(
    entity: ResolvedEntity,
    *,
    query: str,
    today: date,
    start_date: str | None = None,
    end_date: str | None = None,
) -> RoutePlan:
    start_value, end_value = _parse_date_range(query, today=today)
    if start_date:
        start_value = start_date
    if end_date:
        end_value = end_date
    return RoutePlan(
        intent="quote_history",
        endpoint="/cmd_history_quotation",
        payload={
            "codes": entity.symbol,
            "indicators": HISTORY_INDICATORS,
            "startdate": start_value,
            "enddate": end_value,
        },
        entity=entity,
    )


def build_market_snapshot_plan(
    query: str | None = None,
    *,
    entity: ResolvedEntity | None = None,
) -> RoutePlan:
    entity = entity or resolve_common_index_entity(query or "")
    if entity is not None:
        codes = entity.symbol
    else:
        codes = ",".join(symbol for _, symbol in DEFAULT_MARKET_ENTITIES)
    return RoutePlan(
        intent="market_snapshot",
        endpoint="/real_time_quotation",
        payload={"codes": codes, "indicators": MARKET_SNAPSHOT_INDICATORS},
        entity=entity,
    )


def build_fundamental_plan(entity: ResolvedEntity) -> RoutePlan:
    target = entity.symbol
    return RoutePlan(
        intent="fundamental_basic",
        endpoint="/smart_stock_picking",
        payload={
            "searchstrings": [
                FINANCIAL_QUERY_TEMPLATE.format(target=target),
                VALUATION_QUERY_TEMPLATE.format(target=target),
                FORECAST_QUERY_TEMPLATE.format(target=target),
            ],
            "searchtype": "stock",
        },
        entity=entity,
    )


def build_limit_up_plan(query: str) -> RoutePlan:
    return RoutePlan(
        intent="limit_up_screen",
        endpoint="/smart_stock_picking",
        payload={"searchstring": query, "searchtype": "stock"},
        entity=None,
    )


def build_leaderboard_plan(query: str) -> RoutePlan:
    return RoutePlan(
        intent="leaderboard_screen",
        endpoint="/smart_stock_picking",
        payload={
            "searchstring": query,
            "searchtype": "stock",
        },
        entity=None,
    )


def build_entity_profile_plan(entity: ResolvedEntity, query: str) -> RoutePlan:
    return RoutePlan(
        intent="entity_profile",
        endpoint="/smart_stock_picking",
        payload={
            "searchstring": _normalize_entity_profile_query(query, entity),
            "searchtype": "stock",
        },
        entity=entity,
    )


def build_capital_flow_plan(query: str) -> RoutePlan:
    return RoutePlan(
        intent="capital_flow",
        endpoint="/smart_stock_picking",
        payload={"searchstring": query, "searchtype": "stock"},
        entity=None,
    )


def build_generic_smart_query_plan(
    query: str,
    *,
    entity: ResolvedEntity | None = None,
) -> RoutePlan:
    searchstring = _normalize_generic_smart_query(query, entity)
    note = "route_source=ifind_smart_stock_picking"
    if searchstring != query:
        note = f"{note}; normalized_casual_query"
    return RoutePlan(
        intent="generic_smart_query",
        endpoint="/smart_stock_picking",
        payload={"searchstring": searchstring, "searchtype": "stock"},
        entity=entity,
        note=note,
    )


def build_trading_calendar_plan(query: str, *, today: date) -> RoutePlan:
    start = _parse_calendar_start_date(query, today=today)
    end = start + timedelta(days=14)
    return RoutePlan(
        intent="trading_calendar",
        endpoint="/date_sequence",
        payload={
            "codes": "000001.SH",
            "startdate": start.isoformat(),
            "enddate": end.isoformat(),
            "functionpara": {"Days": "Tradedays", "Fill": "Omit"},
            "indipara": [
                {
                    "indicator": "ths_close_price_stock",
                    "indiparams": ["", "100", ""],
                }
            ],
        },
        entity=ResolvedEntity(
            raw="A股交易日历",
            symbol="000001.SH",
            name="上证指数",
            entity_type="index",
        ),
        note="route_source=ifind_date_sequence; response time 字段即 iFinD 返回的交易日序列",
    )


def resolve_common_index_entity(text: str) -> ResolvedEntity | None:
    normalized = (text or "").strip().lower()
    for alias, (name, symbol) in sorted(
        INDEX_ALIASES.items(),
        key=lambda item: len(item[0]),
        reverse=True,
    ):
        if alias in normalized:
            return ResolvedEntity(raw=text, symbol=symbol, name=name, entity_type="index")
    return None


def extract_entity_from_search_payload(raw: str, payload: dict[str, object]) -> ResolvedEntity | None:
    if not isinstance(payload, dict):
        return None
    tables = payload.get("tables")
    if not isinstance(tables, list) or not tables:
        return None
    first = tables[0]
    if not isinstance(first, dict):
        return None
    table = first.get("table")
    if not isinstance(table, dict):
        return None

    symbol = _first_text(table, ("股票代码", "证券代码", "thscode"))
    if not symbol:
        return None
    normalized_symbol = normalize_symbol(symbol)
    name = _first_text(table, ("股票简称", "证券简称", "股票名称", "证券名称"))
    entity_type: EntityType = "index" if _is_known_index_symbol(normalized_symbol) else "stock"
    return ResolvedEntity(raw=raw, symbol=normalized_symbol, name=name, entity_type=entity_type)


def normalize_symbol(text: str) -> str:
    raw = (text or "").strip().upper()
    if not raw:
        return raw
    if raw in {"000001.SH", "399001.SZ", "399006.SZ", "000300.SH"}:
        return raw
    if raw.startswith(("SH", "SZ", "BJ")) and raw[2:].isdigit():
        return f"{raw[2:]}.{raw[:2]}"
    if len(raw) == 8 and raw[:6].isdigit() and raw[6:] in {"SH", "SZ", "BJ"}:
        return f"{raw[:6]}.{raw[6:]}"
    if "." in raw:
        code, market = raw.split(".", 1)
        market = market.upper()
        return f"{code}.{market}"
    if not raw.isdigit() or len(raw) != 6:
        return raw
    if raw.startswith(_ETF_SH_PREFIXES):
        return f"{raw}.SH"
    if raw.startswith(_ETF_SZ_PREFIXES):
        return f"{raw}.SZ"
    if _is_bse_code(raw):
        return f"{raw}.BJ"
    if raw.startswith(("600", "601", "603", "605", "688")):
        return f"{raw}.SH"
    return f"{raw}.SZ"


def _detect_intent(query: str) -> Intent:
    lowered = query.lower()
    if any(pattern in lowered for pattern in _LIMIT_UP_PATTERNS):
        return "limit_up_screen"
    if any(pattern in lowered for pattern in _CAPITAL_FLOW_PATTERNS):
        return "capital_flow"
    if _is_trading_calendar_query(lowered):
        return "trading_calendar"
    if _is_leaderboard_query(lowered):
        return "leaderboard_screen"
    if any(pattern in lowered for pattern in _PROFILE_PATTERNS):
        return "entity_profile"
    if any(
        pattern in lowered
        for pattern in _FUNDAMENTAL_PATTERNS + _PRECISE_FINANCIAL_METRIC_PATTERNS
    ):
        return "fundamental_basic"
    if any(pattern in lowered for pattern in _COMMON_A_SHARE_SMART_PATTERNS):
        return "generic_smart_query"
    if _looks_like_dated_price_lookup(query, lowered):
        return "quote_history"
    if _DATE_RANGE_RE.search(query) or any(pattern in lowered for pattern in _HISTORY_PATTERNS):
        return "quote_history"
    if any(pattern in lowered for pattern in _MARKET_PATTERNS):
        return "market_snapshot"
    return "quote_realtime"


def _is_leaderboard_query(lowered_query: str) -> bool:
    has_metric = any(
        pattern in lowered_query
        for pattern in (
            "成交额",
            "成交金额",
            "涨幅",
            "跌幅",
            "换手率",
            "振幅",
            "量比",
            "领涨",
            "领跌",
        )
    )
    if not has_metric:
        return False
    return any(pattern in lowered_query for pattern in _LEADERBOARD_PATTERNS) or any(
        pattern in lowered_query
        for pattern in (
            "最大",
            "最高",
            "最多",
            "靠前",
            "高的",
            "低的",
            "最好",
            "最差",
        )
    )


def _is_trading_calendar_query(lowered_query: str) -> bool:
    if not any(pattern in lowered_query for pattern in _TRADING_CALENDAR_PATTERNS):
        return False
    stock_lifecycle_terms = ("上市日期", "申购", "中签", "打新", "停牌", "复牌")
    return not any(term in lowered_query for term in stock_lifecycle_terms)


def _needs_manual_lookup(query: str) -> bool:
    lowered = query.lower()
    return any(pattern in lowered for pattern in _MANUAL_LOOKUP_PATTERNS)


def _is_blank_or_punctuation_only(query: str) -> bool:
    return not re.search(r"[A-Za-z0-9\u4e00-\u9fff]", query or "")


def _resolve_entity(
    query: str,
    entity_lookup: Callable[[str], ResolvedEntity | None],
) -> ResolvedEntity | None:
    index_entity = resolve_common_index_entity(query)
    if index_entity is not None:
        return index_entity

    symbol_candidate = _extract_symbol_candidate(query)
    if symbol_candidate:
        normalized_symbol = normalize_symbol(symbol_candidate)
        entity_type: EntityType = "index" if _is_known_index_symbol(normalized_symbol) else "stock"
        known_name = _known_index_name(normalized_symbol)
        return ResolvedEntity(
            raw=symbol_candidate,
            symbol=normalized_symbol,
            name=known_name,
            entity_type=entity_type,
        )

    entity_hint = _extract_entity_hint(query)
    if not entity_hint:
        return None
    alias_entity = resolve_popular_stock_alias(query, entity_hint=entity_hint)
    if alias_entity is not None:
        lookup_entity = None
        if _is_entity_hint_likely_security_name(entity_hint):
            lookup_entity = entity_lookup(entity_hint)
        if lookup_entity is None or _is_alias_lookup_mismatch(alias_entity, lookup_entity):
            return alias_entity
        return lookup_entity
    if not _is_entity_hint_likely_security_name(entity_hint):
        return None
    return entity_lookup(entity_hint)


def resolve_popular_stock_alias(
    text: str,
    *,
    entity_hint: str | None = None,
) -> ResolvedEntity | None:
    normalized_text = (text or "").strip().lower()
    normalized_hint = (entity_hint or "").strip().lower()
    if not normalized_text and not normalized_hint:
        return None

    for alias, (name, symbol) in sorted(
        POPULAR_STOCK_ALIASES.items(),
        key=lambda item: len(item[0]),
        reverse=True,
    ):
        normalized_alias = alias.lower()
        if (
            normalized_alias not in normalized_text
            and normalized_alias not in normalized_hint
        ):
            continue
        if _alias_match_is_obviously_not_a_stock(normalized_text, normalized_alias):
            continue
        return ResolvedEntity(
            raw=alias,
            symbol=symbol,
            name=name,
            entity_type="stock",
        )
    return None


def _extract_symbol_candidate(query: str) -> str | None:
    match = _SYMBOL_RE.search(query or "")
    if not match:
        return None
    return match.group(0)


def _extract_entity_hint(query: str) -> str:
    stripped = query or ""
    for pattern in _QUERY_NOISE_PATTERNS:
        stripped = re.sub(pattern, " ", stripped, flags=re.IGNORECASE)
    stripped = re.sub(r"\d{4}-\d{2}-\d{2}", " ", stripped)
    stripped = re.sub(r"\d{4}年(?:\d{1,2}月)?(?:\d{1,2}[日号]?)?", " ", stripped)
    stripped = re.sub(r"(?<!\d)\d{4}(?!\d)", " ", stripped)
    stripped = re.sub(r"(?:近|最近)?[一二三四五六七八九十\d]+年", " ", stripped)
    stripped = re.sub(r"(?:近|最近)?[一二三四五六七八九十\d]+(?:个)?季度", " ", stripped)
    stripped = re.sub(r"[，,。？?！!、：:；;（）()【】《》〈〉「」『』\[\]{}\"'“”‘’]", " ", stripped)
    stripped = re.sub(r"\s+", "", stripped)
    return stripped.strip("的和与及是为到至")


def _is_entity_hint_likely_security_name(entity_hint: str) -> bool:
    if not entity_hint:
        return False
    lowered = entity_hint.lower()
    broad_or_condition_terms = (
        "a股",
        "股票",
        "哪些",
        "哪个",
        "什么",
        "下一个",
        "下个",
        "上一个",
        "上个",
        "时候",
        "哪天",
        "日期",
        "多少",
        "啥",
        "买啥",
        "买了啥",
        "卖啥",
        "卖了啥",
        "哪儿",
        "哪里",
        "往哪",
        "往哪里",
        "开不开盘",
        "半导体",
        "新能源",
        "白酒",
        "cpo",
        "算力",
        "机器人",
        "银行股",
        "板块",
        "行业",
        "产业链",
        "概念",
        "龙头",
        "低于",
        "高于",
        "大于",
        "小于",
        "不低于",
        "不高于",
        "近5日",
        "近10日",
        "放量",
        "缩量",
        "申购",
        "中签",
        "交易日",
        "休市",
        "停牌",
        "复牌",
        "跌停",
        "外资",
        "主力",
        "top",
    )
    return not any(term in lowered for term in broad_or_condition_terms)


def _is_alias_lookup_mismatch(
    alias_entity: ResolvedEntity,
    lookup_entity: ResolvedEntity,
) -> bool:
    if lookup_entity.entity_type != "stock":
        return True
    return normalize_symbol(lookup_entity.symbol) != alias_entity.symbol


def _alias_match_is_obviously_not_a_stock(
    normalized_text: str,
    normalized_alias: str,
) -> bool:
    if f"{normalized_alias}市" in normalized_text:
        return True
    if normalized_alias == "药明" and any(
        phrase in normalized_text for phrase in ("药明生物", "港股", "美股", "h股")
    ):
        return True
    return f"{normalized_alias}有哪些" in normalized_text and any(
        term in normalized_text for term in ("上市公司", "地区", "城市", "地方")
    )


def _parse_date_range(query: str, *, today: date) -> tuple[str, str]:
    match = _DATE_RANGE_RE.search(query or "")
    if match:
        return match.group("start"), match.group("end")

    single_date = _extract_single_date(query, today=today)
    if single_date is not None:
        return single_date, single_date

    days = _relative_days(query)
    start = today - timedelta(days=days)
    return start.isoformat(), today.isoformat()


def _extract_single_date(query: str, *, today: date) -> str | None:
    single_date_match = _SINGLE_DATE_RE.search(query or "")
    if single_date_match:
        return single_date_match.group("date")

    month_day_match = _MONTH_DAY_RE.search(query or "")
    if not month_day_match:
        return None
    month = int(month_day_match.group("month"))
    day = int(month_day_match.group("day"))
    try:
        return date(today.year, month, day).isoformat()
    except ValueError:
        return None


def _parse_calendar_start_date(query: str, *, today: date) -> date:
    single_date = _extract_single_date(query, today=today)
    if single_date is not None:
        return date.fromisoformat(single_date)
    lowered = query.lower()
    if "明天" in lowered or "下一个" in lowered or "下个" in lowered:
        return today + timedelta(days=1)
    return today


def _relative_days(query: str) -> int:
    lowered = query.lower()
    explicit_days = re.search(r"近(\d+)天", lowered)
    if explicit_days:
        return max(int(explicit_days.group(1)), 1)
    if any(pattern in lowered for pattern in ("近一周", "最近一周", "近1周")):
        return 7
    if any(pattern in lowered for pattern in ("近一个月", "最近一个月", "近1个月")):
        return 30
    if any(pattern in lowered for pattern in ("近三个月", "最近三个月", "近3个月")):
        return 90
    if "近半年" in lowered:
        return 180
    if "近一年" in lowered:
        return 365
    return 30


def _looks_like_dated_price_lookup(query: str, lowered_query: str) -> bool:
    has_date = bool(_SINGLE_DATE_RE.search(query or "") or _MONTH_DAY_RE.search(query or ""))
    if not has_date:
        return False
    return any(pattern in lowered_query for pattern in _PRICE_FIELD_PATTERNS)


def _is_precise_financial_query(query: str) -> bool:
    lowered = query.lower()
    return any(pattern in lowered for pattern in _PRECISE_FINANCIAL_METRIC_PATTERNS)


def _normalize_entity_profile_query(query: str, entity: ResolvedEntity) -> str:
    if entity.name and any(term in query for term in ("干啥", "干什么", "做什么")):
        return f"{entity.name}主营业务是什么"
    return query


def _normalize_generic_smart_query(
    query: str,
    entity: ResolvedEntity | None,
) -> str:
    if entity is None or not entity.name:
        if "北向" in query and any(term in query for term in ("买啥", "买了啥", "流入哪些")):
            return "北向资金持股增加前十"
        return query

    casual_terms = ("有啥", "啥", "怎么样", "咋样", "有吗", "消息")
    if not any(term in query for term in casual_terms):
        return query
    if "分红" in query or "派息" in query:
        return f"{entity.name}分红记录"
    if "研报" in query or "评级" in query or "目标价" in query:
        return f"{entity.name}研报"
    if "公告" in query or "消息" in query or "资讯" in query or "新闻" in query:
        return f"{entity.name}最近公告"
    return query


def _has_generic_query_signal(query: str) -> bool:
    lowered = query.lower()
    return any(
        pattern in lowered
        for pattern in (
            _COMMON_A_SHARE_SMART_PATTERNS
            + _CAPITAL_FLOW_PATTERNS
            + _MARKET_PATTERNS
            + _LEADERBOARD_PATTERNS
            + _LIMIT_UP_PATTERNS
        )
    )


def _manual_lookup_plan(note: str) -> RoutePlan:
    return RoutePlan(
        intent="manual_lookup",
        endpoint=None,
        payload=None,
        entity=None,
        note=note,
    )


def _is_bse_code(code: str) -> bool:
    return len(code) == 6 and code.isdigit() and code.startswith(("4", "8", "92"))


def _is_known_index_symbol(symbol: str) -> bool:
    return any(symbol == known_symbol for _, known_symbol in KNOWN_INDEX_ENTITIES)


def _known_index_name(symbol: str) -> str | None:
    for name, known_symbol in KNOWN_INDEX_ENTITIES:
        if symbol == known_symbol:
            return name
    return None


def _first_text(table: dict[str, object], keys: tuple[str, ...]) -> str | None:
    for key in keys:
        value = table.get(key)
        if isinstance(value, list):
            value = value[0] if value else None
        if isinstance(value, str) and value.strip():
            return value.strip()
    return None


path: scripts/runtime/tonghuashun_ifind_skill/state.py
content: from __future__ import annotations

import json
import tempfile
from pathlib import Path

from tonghuashun_ifind_skill.models import TokenBundle


class TokenStateStore:
    def __init__(self, path: Path):
        self.path = path

    def save(self, bundle: TokenBundle) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=self.path.parent,
            delete=False,
        ) as tmp_file:
            json.dump(bundle.to_dict(), tmp_file)
            temp_path = Path(tmp_file.name)
        temp_path.replace(self.path)

    def load(self) -> TokenBundle | None:
        if not self.path.exists():
            return None
        try:
            payload = json.loads(self.path.read_text(encoding="utf-8"))
            return TokenBundle.from_dict(payload)
        except (OSError, ValueError, TypeError):
            return None


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)