Skill: derrors/stock-analysis-report (version 1.0.9)

--- name: stock-analysis description: | A股市场分析与个股深度分析技能。支持基本面（财务指标/估值分位）、资金面（主力资金/DDX/DDY/DDZ）、技术面（均线/乖离率/量比）、筹码分布、舆情情报（新闻/研报/公告）等多维度分析，输出结构化评分与操作建议。 Triggers: "分析股票", "个股分析", "市场分析", "A股分析", "stock analysis", "analyze stock", "market analysis", "股票评分", "买卖建议", "涨停分析" Does NOT trigger: - 简单的股价查询（无需深度分析） - 港股/美股/非A股市场分析 - 基金/债券/期货分析 Output: 结构化分析报告（评分0-100、操作方向、买卖点位、风险提示、检查清单） version: 1.0.9 license: MIT-0 metadata: openclaw: requires: env: - LLM_API_KEY - LLM_BASE_URL - LLM_MODEL bins: - python3 optional: env: - MX_APIKEY - SERPAPI_KEY - TAVILY_KEY - BRAVE_KEY - BOCHA_KEY - BIAS_THRESHOLD - NEWS_MAX_AGE_DAYS - ENABLE_CHIP - LOG_LEVEL primaryEnv: LLM_API_KEY emoji: "📈" install: - kind: uv command: pip install -r requirements.txt --- # A股股票市场分析 Skill ## 功能 ### 个股分析 输入股票代码，输出结构化分析结果： - **基本面**：净利润、营收、ROE、毛利率、资产负债率、机构持股比例、盈利预测 - **估值**：PE(TTM)、PB、历史分位 - **资金面**：主力资金流向（超大单/大单/中单/小单净额）、DDX/DDY/DDZ 指标 - **技术面**：MA5/MA10/MA20/MA60 均线、多头排列、乖离率、量比 - **筹码分布**：获利比例、平均成本、集中度 - **舆情情报**：妙想金融资讯（新闻/研报/公告，LLM 分析）+ 多引擎新闻搜索 - **实时行情**：当前价、涨跌幅、成交量、换手率 - **分析结论**：评分 + 操作方向 + 买卖点位 + 检查清单 + 风险提示 ### 市场分析 每日 A 股市场复盘： - 主要指数（上证/深证/创业板） - 涨跌统计（涨跌家数、涨停跌停数） - 板块排名（领涨/领跌 Top5） - 市场情绪判断 + 操作建议 ## 使用方式 ### 生成分析报告（推荐） ```bash # 个股分析报告 → 保存到 reports/{代码}_{日期}.md python3 {baseDir}/scripts/report.py stock 600519 # 市场分析报告 → 保存到 reports/market_{日期}.md python3 {baseDir}/scripts/report.py market # 同时输出 JSON python3 {baseDir}/scripts/report.py stock 600519 --json # 自定义输出目录 python3 {baseDir}/scripts/report.py stock 600519 -o ./my-reports ``` ### JSON 输出 ```bash python3 {baseDir}/scripts/analyze_stock.py 600519 python3 {baseDir}/scripts/analyze_market.py ``` ### Handler 调用 ```python from src.index import handler result = await handler({"mode": "stock", "code": "600519"}) result = await handler({"mode": "market"}) result = await handler({"mode": "stock", "code": "600519", "save": True}) result = await handler({"mode": "stock", "code": "600519", "save": True, "output_dir": "./reports"}) ``` ## 输入格式 | 字段 | 类型 | 必填 | 说明 | | ----------- | ------- | ------------- | ----------------------------- | | mode | string | 是 | "stock" 或 "market" | | code | string | mode=stock时必填 | A股股票代码，如 "600519" | | save | boolean | 否 | 是否保存 Markdown 报告到 reports/ 目录 | | output_dir | string | 否 | 自定义报告输出目录（需 save=true） | ## 输出格式 ### 个股分析结果 ```json { "stock_code": "600519", "stock_name": "贵州茅台", "core_conclusion": "一句话核心结论", "score": 75, "action": "买入/观望/卖出", "trend": "看多/震荡/看空", "buy_price": 1800.0, "stop_loss_price": 1700.0, "target_price": 2000.0, "checklist": [{"condition": "...", "status": "✅/❌", "detail": "..."}], "risk_alerts": ["风险1", "风险2"], "positive_catalysts": ["利好1"], "strategy": "买卖策略建议", "stock_info": {"code": "600519", "name": "贵州茅台", "industry": "白酒", "market_cap": 2200000000000}, "realtime": {"price": 1800.0, "change_pct": 1.5, "turnover_rate": 0.8}, "tech": {"ma5": 1780.0, "ma10": 1760.0, "ma20": 1740.0, "ma60": 1700.0, "is_bullish_alignment": true, "bias": 2.1, "volume_ratio": 1.2}, "chip": {"profit_ratio": 75.0, "avg_cost": 1720.0, "concentration": 12.5}, "capital_flow": {"super_large_net": 500000000, "large_net": 200000000, "ddx": 0.26, "ddy": 0.15, "ddz": 5.3}, "valuation": {"pe_ttm": 28.5, "pb": 8.2, "pe_percentile": 45.0, "pb_percentile": 30.0}, "financial": {"net_profit": 55000000000, "revenue": 120000000000, "roe": 30.5, "gross_margin": 91.2, "debt_ratio": 25.3, "institution_holding_pct": 65.0}, "news": [{"title": "...", "snippet": "资讯摘要", "date": "2025-01-15", "source": "中泰证券·研报·买入", "info_type": "report"}], "raw_report": "LLM完整分析报告(Markdown)", "disclaimer": "仅供参考，不构成投资建议" } ``` ### 市场分析结果 ```json { "date": "2025-01-15", "core_conclusion": "一句话核心结论", "indices": [{"name": "上证指数", "close": 3200.0, "change_pct": 0.5}], "statistics": {"up_count": 3000, "down_count": 1500, "limit_up_count": 50}, "top_sectors": [{"name": "半导体", "change_pct": 3.2}], "bottom_sectors": [{"name": "房地产", "change_pct": -1.5}], "sentiment": "偏多/中性/偏空", "strategy": "操作建议", "raw_report": "LLM完整复盘报告(Markdown)" } ``` ## 数据源 ### 推荐配置：妙想金融 配置 `MX_APIKEY` 后，妙想金融自动成为行情数据、财务/资金/估值、资讯搜索的**最高优先级数据源**，覆盖能力最全、数据质量最高。推荐优先配置。 👉 前往 [妙想 Skills 页面](https://dl.dfcfs.com/m/itc4) 获取 API Key ### 行情数据 三级自动容灾，并行竞争 + 超时控制： | 优先级 | 数据源 | 覆盖能力 | 需要 Key | | --- | ------------ | ----------------- | ------------- | | 0 | **妙想金融** | 行情 + 财务 + 资金 + 估值 | ✅ `MX_APIKEY` | | 1 | **Efinance** | 行情 + 板块 + 市场统计 | ❌ | | 2 | **AkShare** | 行情 + 筹码 + 板块 | ❌ | - 配置 `MX_APIKEY` 后妙想自动成为最高优先级；未配置时从 Efinance 开始 - 所有数据源**并行请求**，按优先级取第一个有效结果；日K线取行数最多的结果 - 单数据源超时 10 秒自动降级，任一数据源异常不影响整体分析 ### 资讯搜索 五级自动容灾，串行降级： | 优先级 | 搜索引擎 | 返回内容 | 需要 Key | | --- | -------- | ---------------------- | --------------- | | 0 | **妙想搜索** | 完整正文 + 新闻/研报/公告 + 机构评级 | ✅ `MX_APIKEY` | | 1 | SerpAPI | snippet | ✅ `SERPAPI_KEY` | | 2 | Tavily | snippet | ✅ `TAVILY_KEY` | | 3 | Brave | snippet | ✅ `BRAVE_KEY` | | 4 | Bocha | snippet | ✅ `BOCHA_KEY` | - 妙想搜索返回完整正文，直传 LLM 进行分析；其他引擎仅返回 snippet - 研报类型含机构名称和评级（如"中泰证券·研报·买入"），LLM 可据此参考机构观点 详见 references/data-sources.md。 ## 性能特性 - **并行数据采集**：个股分析的 7 个数据维度（日K线、实时行情、筹码、资金、估值、财务、资讯）并行获取，总耗时取决于最慢的单项 - **并行数据源竞争**：同一数据维度在多个数据源间并行请求，按优先级取第一个有效结果 - **超时保护**：单数据源请求超时 10 秒自动降级，避免阻塞 - **资讯正文直传**：妙想资讯返回完整正文，直传 LLM 在最终分析时一并阅读，保留完整投资信息 ## 环境变量 | 变量 | 必填 | 说明 | | -------------- | -- | ------------------------------------------------ | | LLM_API_KEY | 是 | LLM API Key | | LLM_BASE_URL | 是 | OpenAI 兼容 API 地址 | | LLM_MODEL | 是 | 模型名称 | | MX_APIKEY | 否 | 妙想金融 API Key（[前往获取](https://dl.dfcfs.com/m/itc4)），配置后自动成为行情数据 + 财务/资金/估值 + 资讯搜索第一优先级 | | SERPAPI_KEY | 否 | SerpAPI 搜索 Key | | TAVILY_KEY | 否 | Tavily 搜索 Key | | BRAVE_KEY | 否 | Brave 搜索 Key | | BOCHA_KEY | 否 | 博查搜索 Key | ## 注意事项 - Efinance / AkShare 为免费接口，无需注册；妙想金融需配置 `MX_APIKEY` - 分析结果仅供参考，不构成投资建议

path: README.md
content: <div align="center">

# 📈 A 股股票市场分析 Skill

专为 OpenClaw 等智能体设计的 A 股股票分析技能

[![License: MIT-0](https://img.shields.io/badge/License-MIT--0-yellow.svg)](LICENSE)
[![Python 3.9+](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://www.python.org/)
[![OpenClaw Skill](https://img.shields.io/badge/OpenClaw-Skill-green.svg)](https://github.com/openclaw)

基于 [daily\_stock\_analysis](https://github.com/ZhuLinsen/daily_stock_analysis) 项目精简重写，去除 WebUI、通知推送、Agent 问股等非核心功能，聚焦分析能力本身。

</div>

***

## ✨ 功能特性

### 📊 个股分析

输入股票代码，输出结构化分析结果：

- **技术面分析**：MA5/MA10/MA20/MA60 均线、多头排列判断、乖离率、量比
- **筹码分布**：获利比例、平均成本、集中度
- **舆情情报**：妙想金融资讯（新闻/研报/公告，LLM 分析）+ 多引擎新闻搜索
- **实时行情**：当前价、涨跌幅、成交量、换手率等
- **分析结论**：一句话核心结论 + 评分 + 操作方向 + 精确买卖点位 + 操作检查清单 + 买卖策略

### 🌐 市场分析

每日 A 股市场复盘：

- **主要指数**：上证指数、深证成指、创业板指
- **市场统计**：涨跌家数、涨停跌停数
- **板块排名**：领涨 / 领跌板块 Top5
- **分析结论**：市场情绪判断 + 操作建议 + 完整复盘报告

### 🔄 多源数据架构

#### 推荐配置：妙想金融

配置 `MX_APIKEY` 后，妙想金融自动成为行情数据、财务/资金/估值、资讯搜索的**最高优先级数据源**，覆盖能力最全、数据质量最高。推荐优先配置。

👉 前往 [妙想 Skills 页面](https://dl.dfcfs.com/m/itc4) 获取 API Key

#### 行情数据采用自动容灾策略：

| 优先级 | 数据源          | 说明                          |    需要 Token   |
| :-: | ------------ | --------------------------- | :-----------: |
|  0  | **妙想金融**     | 东财妙想 API，覆盖行情 + 财务数据        | ✅ `MX_APIKEY` |
|  1  | **Efinance** | 基于东财接口，覆盖日 K、实时行情、板块排名、市场统计 |       ❌       |
|  2  | **AkShare**  | 东财 + 新浪双通道，额外支持筹码分布         |       ❌       |

> - 配置 `MX_APIKEY` 后妙想自动成为最高优先级；未配置时从 Efinance 开始
> - 任意数据源异常时自动切换到下一级，无需人工干预

## 🚀 快速开始

### 方式一：通过 ClawHub 安装（推荐）

使用 OpenClaw CLI 一键安装：

```bash
openclaw skill install stock-analysis-report
```

安装后即可在 OpenClaw 中直接使用：

```
分析贵州茅台
市场分析
```

### 方式二：本地开发

#### 1. 安装依赖

```bash
pip install -r requirements.txt
```

#### 2. 配置环境变量

```bash
cp .env.example .env
```

编辑 `.env` 文件，填入你的 API Key：

```env
# LLM 配置（必填）— 支持 DeepSeek 和 OpenAI 兼容模型
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_API_KEY=your-api-key-here
LLM_MODEL=deepseek-chat

# 妙想金融数据（可选，配置后自动成为第一优先级数据源 + 资讯搜索源）
MX_APIKEY=

# 新闻搜索（至少配置一个，可选）
SERPAPI_KEY=
TAVILY_KEY=
BRAVE_KEY=
BOCHA_KEY=
```

> 💡 行情数据源（Efinance / AkShare）均为免费接口，无需配置任何 Token。

#### 3. 调用分析

#### 生成 Markdown 报告（推荐）

```bash
# 个股分析报告 → reports/{代码}_{日期}.md
python3 scripts/report.py stock 600519

# 市场分析报告 → reports/market_{日期}.md
python3 scripts/report.py market

# 同时输出 JSON
python3 scripts/report.py stock 600519 --json

# 自定义输出目录
python3 scripts/report.py stock 600519 -o ./my-reports
```

#### JSON 输出

```bash
python3 scripts/analyze_stock.py 600519    # 个股分析
python3 scripts/analyze_market.py          # 市场分析
```

#### Python API

```python
import asyncio
from src.index import analyze_stock, analyze_market

# 个股分析 + 保存报告
result = asyncio.run(analyze_stock("600519", save=True))
print(result.core_conclusion)   # 一句话核心结论
print(result.action)            # 买入/观望/卖出

# 市场分析 + 保存报告
result = asyncio.run(analyze_market(save=True))
print(result.sentiment)         # 偏多/中性/偏空
```

#### OpenClaw Handler

```python
from src.index import handler

result = await handler({"mode": "stock", "code": "600519"})
result = await handler({"mode": "market", "save": True})
```

<details>
<summary>自定义配置</summary>

```python
from src.config import SkillConfig

config = SkillConfig(
    llm_base_url="https://api.openai.com/v1",
    llm_api_key="sk-xxx",
    llm_model="gpt-4o",
)

result = asyncio.run(analyze_stock("000001", config=config))
```

</details>

## 📁 项目结构

```
stock-analysis/
├── SKILL.md                     # Skill 定义（OpenClaw 标准入口）
├── manifest.yaml                # 机器可读元数据 + 输入输出 Schema
├── src/                         # 源代码
│   ├── index.py                 # Skill 入口（handler + analyze_stock/market）
│   ├── config.py                # 配置管理（环境变量 + .env）
│   ├── models.py                # 数据模型定义
│   ├── report.py                # Markdown 报告生成
│   ├── analyzer/
│   │   ├── stock.py             # 个股分析引擎
│   │   ├── market.py            # 市场分析引擎
│   │   └── prompts.py           # Prompt 模板
│   ├── data/
│   │   ├── provider.py          # 数据源抽象基类
│   │   ├── efinance_provider.py # Efinance 数据源（优先级 1）
│   │   ├── akshare_provider.py  # AkShare 数据源（优先级 2）
│   │   ├── manager.py           # 数据源管理器（多源自动容灾）
│   │   └── utils.py             # 公共工具（缓存/统计/限流）
│   ├── search/
│   │   ├── base.py              # 搜索引擎抽象基类
│   │   ├── miaoxiang.py         # 妙想金融资讯搜索（优先级 0）
│   │   ├── serpapi.py           # SerpAPI 搜索
│   │   ├── tavily.py            # Tavily 搜索
│   │   ├── brave.py             # Brave 搜索
│   │   └── bocha.py             # Bocha 搜索
│   └── llm/
│       └── client.py            # LLM 客户端（OpenAI 兼容接口）
├── scripts/                     # CLI 入口脚本
│   ├── report.py                # 报告生成器（推荐入口）
│   ├── analyze_stock.py         # 个股分析 JSON 输出
│   └── analyze_market.py        # 市场分析 JSON 输出
├── references/                  # 按需加载的详细文档
│   └── data-sources.md          # 数据源架构详细说明
├── test/                        # 测试
├── requirements.txt
├── config.example.yaml          # YAML 格式示例配置
├── .env.example
└── .gitignore
```

## 🏗️ 架构设计

```
┌──────────────────────────────────────────┐
│         SKILL.md / manifest.yaml          │
│         OpenClaw Skill 定义层             │
└──────────────────┬───────────────────────┘
                   │
┌──────────────────▼───────────────────────┐
│         src/index.py（接口层）              │
│  handler()  analyze_stock()  analyze_market() │
└──────────────────┬───────────────────────┘
                   │
┌──────────────────▼───────────────────────┐
│        src/analyzer/（分析层）              │
│     组装数据 + 构建 Prompt + 调用 LLM       │
└──┬───────────────┬───────────┬───────────┘
   │               │           │
   ▼               ▼           ▼
┌────────────┐ ┌──────┐ ┌──────────┐
│  src/data/ │ │search│ │   llm/   │
│   数据层    │ │搜索层 │ │  模型层   │
│ 三级自动容灾 │ │5 引擎 │ │DeepSeek  │
│            │ │(可扩展)│ │(OpenAI兼容)│
│ 妙想(可选)  │ └──────┘ └──────────┘
│  ↓ 失败     │
│ Efinance   │
│  ↓ 失败     │
│ AkShare    │
└────────────┘
```

每层均通过抽象基类解耦，可独立扩展：

- **数据层**：实现 `MarketDataProvider` 即可接入新数据源，`DataProviderManager` 自动管理容灾切换
- **搜索层**：实现 `NewsSearchEngine` 即可接入新搜索引擎
- **模型层**：所有 OpenAI 兼容 API 均可直接使用

### 数据源能力矩阵

| 能力      | 妙想金融 | Efinance | AkShare |
| ------- | :--: | :------: | :-----: |
| 股票基本信息  |   ✅  |     ✅    |    ✅    |
| 日 K 线数据 |   ✅  |     ✅    |    ✅    |
| 实时行情    |   ✅  |     ✅    |    ✅    |
| 筹码分布    |   ❌  |     ❌    |    ✅    |
| 板块排名    |   ❌  |     ✅    |    ✅    |
| 市场统计    |   ❌  |     ✅    |    ✅    |
| 指数数据    |   ✅  |     ✅    |    ✅    |
| 财务数据    |   ✅  |     ❌    |    ❌    |
| 主力资金    |   ✅  |     ❌    |    ❌    |
| 估值数据    |   ✅  |     ❌    |    ❌    |

### 资讯搜索架构

| 优先级 | 搜索引擎     | 数据源          |     需要 Key    | 返回内容            |
| :-: | -------- | ------------ | :-----------: | --------------- |
|  0  | **妙想搜索** | 东财妙想资讯 API   | ✅ `MX_APIKEY` | 完整正文 + 新闻/研报/公告 |
|  1  | SerpAPI  | Google 搜索    |       ✅       | snippet         |
|  2  | Tavily   | Tavily AI 搜索 |       ✅       | snippet         |
|  3  | Brave    | Brave 搜索     |       ✅       | snippet         |
|  4  | Bocha    | 博查搜索         |       ✅       | snippet         |

> - 配置 `MX_APIKEY` 后妙想搜索自动成为最高优先级，返回新闻 + 研报 + 公告三种类型
> - 妙想搜索返回完整正文，直传 LLM 进行分析；其他引擎仅返回 snippet
> - 研报类型含机构名称和评级（如"中泰证券·研报·买入"），LLM 可据此参考机构观点

### 反封禁策略

参照原项目实现，内置多项反封禁措施：

- 随机 User-Agent 轮换（5 组 UA）
- 请求间随机 Sleep（1.5 \~ 3.0 秒抖动）
- 全市场实时行情缓存（TTL 600 秒）
- 涨停 / 跌停精确计算（科创板 / 创业板 20%、北交所 30%、ST 5%、普通 10%）

## ⚙️ 配置说明

| 环境变量                |  必填 | 默认值                           | 说明                                                                  |
| ------------------- | :-: | ----------------------------- | ------------------------------------------------------------------- |
| `LLM_BASE_URL`      |  ✅  | `https://api.deepseek.com/v1` | OpenAI 兼容 API 地址                                                    |
| `LLM_API_KEY`       |  ✅  | —                             | API Key                                                             |
| `LLM_MODEL`         |  ✅  | `deepseek-chat`               | 模型名称                                                                |
| `SERPAPI_KEY`       |  ❌  | —                             | SerpAPI Key                                                         |
| `TAVILY_KEY`        |  ❌  | —                             | Tavily Key                                                          |
| `BRAVE_KEY`         |  ❌  | —                             | Brave Search Key                                                    |
| `BOCHA_KEY`         |  ❌  | —                             | 博查搜索 Key                                                            |
| `MX_APIKEY`         |  ❌  | —                             | 妙想金融数据 API Key（[前往获取](https://dl.dfcfs.com/m/itc4)），配置后自动成为第一优先级数据源 |
| `BIAS_THRESHOLD`    |  ❌  | `5.0`                         | 乖离率阈值（%）                                                            |
| `NEWS_MAX_AGE_DAYS` |  ❌  | `3`                           | 新闻最大时效（天）                                                           |
| `ENABLE_CHIP`       |  ❌  | `true`                        | 是否启用筹码分布                                                            |
| `LOG_LEVEL`         |  ❌  | `INFO`                        | 日志级别：DEBUG/INFO/WARNING/ERROR                                       |

## 📋 分析结果结构

<details>
<summary>个股分析 — StockAnalysisResult</summary>

| 字段                   | 类型               | 说明                   |
| -------------------- | ---------------- | -------------------- |
| `stock_code`         | str              | 股票代码                 |
| `stock_name`         | str              | 股票名称                 |
| `core_conclusion`    | str              | 一句话核心结论              |
| `score`              | int              | 评分 0-100             |
| `action`             | str              | 操作方向：买入/观望/卖出        |
| `trend`              | str              | 趋势判断：看多/震荡/看空        |
| `buy_price`          | float            | 建议买入价                |
| `stop_loss_price`    | float            | 止损价                  |
| `target_price`       | float            | 目标价                  |
| `checklist`          | list\[CheckItem] | 操作检查清单               |
| `risk_alerts`        | list\[str]       | 风险警报                 |
| `positive_catalysts` | list\[str]       | 利好催化                 |
| `strategy`           | str              | 买卖策略建议               |
| `raw_report`         | str              | LLM 完整分析报告（Markdown） |
| `disclaimer`         | str              | 免责声明                 |

</details>

<details>
<summary>市场分析 — MarketAnalysisResult</summary>

| 字段                | 类型                | 说明                   |
| ----------------- | ----------------- | -------------------- |
| `date`            | str               | 分析日期                 |
| `core_conclusion` | str               | 一句话核心结论              |
| `indices`         | list\[IndexData]  | 主要指数数据               |
| `statistics`      | MarketStatistics  | 涨跌统计                 |
| `top_sectors`     | list\[SectorData] | 领涨板块                 |
| `bottom_sectors`  | list\[SectorData] | 领跌板块                 |
| `sentiment`       | str               | 市场情绪：偏多/中性/偏空        |
| `strategy`        | str               | 操作建议                 |
| `raw_report`      | str               | LLM 完整复盘报告（Markdown） |
| `disclaimer`      | str               | 免责声明                 |

</details>

## ⚠️ 注意事项

- **免 Token 数据源**：Efinance / AkShare 均为免费接口，无需注册；妙想金融需配置 `MX_APIKEY`
- **自动容灾**：任一数据源异常时自动切换至下一级，日志输出 `[数据源切换]` 提示
- **网络环境**：东财接口在企业内网 / 代理下可能受限，系统会自动回退至新浪通道
- **API 限流**：搜索 API 有调用频率限制，频繁调用可能触发限流
- **数据时效**：实时行情基于最新日线数据，盘中数据可能有延迟
- **无状态设计**：Skill 不保存任何分析结果，每次调用独立执行

## 📄 License

本项目基于 [MIT-0 License](LICENSE) 开源。

## ⚖️ 免责声明

本项目所有分析结果仅供参考，不构成投资建议。股市有风险，投资需谨慎。


path: config.example.yaml
content: # LLM 配置（必填）
llm_base_url: "https://api.deepseek.com/v1"
llm_api_key: "your-api-key-here"
llm_model: "deepseek-chat"

# 新闻搜索（至少配置一个）
serpapi_key: ""
tavily_key: ""
brave_key: ""
bocha_key: ""

# 妙想金融数据源（可选）— 配置后自动成为第一优先级
mx_apikey: ""

# 分析参数
bias_threshold: 5.0
news_max_age_days: 3
enable_chip: true

# 日志级别：DEBUG / INFO / WARNING / ERROR
log_level: "INFO"


path: manifest.yaml
content: name: stock-analysis
version: 1.0.9
author: Derrors
license: MIT-0
runtime: python
entry: src/index.py
description: A股市场分析与个股深度分析技能，支持基本面（财务/估值）、资金面（主力资金/DDX）、技术面、筹码分布、舆情情报等多维度分析

categories:
  - finance
  - analysis

requires:
  env:
    - LLM_API_KEY
    - LLM_BASE_URL
    - LLM_MODEL
  bins:
    - python3

optional:
  env:
    - MX_APIKEY
    - SERPAPI_KEY
    - TAVILY_KEY
    - BRAVE_KEY
    - BOCHA_KEY
    - BIAS_THRESHOLD
    - NEWS_MAX_AGE_DAYS
    - ENABLE_CHIP
    - LOG_LEVEL

install:
  - kind: uv
    command: pip install -r requirements.txt

config:
  - key: llm_base_url
    type: string
    required: true
    default: "https://api.deepseek.com/v1"
    description: OpenAI兼容API地址
  - key: llm_api_key
    type: string
    required: true
    description: LLM API Key
  - key: llm_model
    type: string
    required: true
    default: "deepseek-chat"
    description: 模型名称
  - key: mx_apikey
    type: string
    required: false
    description: 妙想金融API Key（配置后自动成为行情数据+财务/资金/估值+资讯搜索第一优先级）
  - key: serpapi_key
    type: string
    required: false
    description: SerpAPI搜索Key
  - key: tavily_key
    type: string
    required: false
    description: Tavily搜索Key
  - key: brave_key
    type: string
    required: false
    description: Brave搜索Key
  - key: bocha_key
    type: string
    required: false
    description: 博查搜索Key
  - key: bias_threshold
    type: number
    required: false
    default: 5.0
    description: 乖离率阈值(%)
  - key: news_max_age_days
    type: integer
    required: false
    default: 3
    description: 新闻最大时效(天)
  - key: enable_chip
    type: boolean
    required: false
    default: true
    description: 是否启用筹码分布
  - key: log_level
    type: string
    required: false
    default: "INFO"
    description: 日志级别 DEBUG/INFO/WARNING/ERROR

input:
  type: object
  properties:
    mode:
      type: string
      enum: [stock, market]
      description: 分析模式
    code:
      type: string
      description: A股股票代码(mode=stock时必填)
    save:
      type: boolean
      description: 是否保存Markdown报告到reports/目录
    output_dir:
      type: string
      description: 自定义报告输出目录(需save=true)
  required: [mode]

output:
  type: object
  properties:
    stock_code:
      type: string
      description: 股票代码
    stock_name:
      type: string
      description: 股票名称
    core_conclusion:
      type: string
      description: 一句话核心结论
    score:
      type: integer
      description: 评分0-100
    action:
      type: string
      description: 操作方向(买入/观望/卖出)
    trend:
      type: string
      description: 趋势判断(看多/震荡/看空)
    buy_price:
      type: number
      description: 建议买入价
    stop_loss_price:
      type: number
      description: 止损价
    target_price:
      type: number
      description: 目标价
    capital_flow:
      type: object
      description: 主力资金流向(超大单/大单/中单/小单净额+DDX/DDY/DDZ)
    valuation:
      type: object
      description: 估值数据(PE_TTM/PB/历史分位)
    financial:
      type: object
      description: 核心财务指标(净利润/营收/ROE/毛利率/资产负债率/机构持股)
    sentiment:
      type: string
      description: 市场情绪(偏多/中性/偏空)
    raw_report:
      type: string
      description: LLM完整分析报告(Markdown)


path: requirements.txt
content: openai>=1.0.0
efinance>=0.5.0
akshare>=1.10.0
numpy>=1.24.0
pandas>=2.0.0
httpx>=0.25.0
requests>=2.28.0
python-dotenv>=1.0.0


path: scripts/analyze_market.py
content: #!/usr/bin/env python3
import argparse
import asyncio
import json
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from src.index import analyze_market, _dataclass_to_dict


def main():
    parser = argparse.ArgumentParser(description="A股市场分析")
    parser.add_argument("--save", action="store_true", help="保存 Markdown 报告到 reports/ 目录")
    parser.add_argument("--output-dir", default=None, help="自定义报告输出目录")
    args = parser.parse_args()

    result = asyncio.run(analyze_market(save=args.save, output_dir=args.output_dir))
    print(json.dumps(_dataclass_to_dict(result), ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/analyze_stock.py
content: #!/usr/bin/env python3
import argparse
import asyncio
import json
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from src.index import analyze_stock, _dataclass_to_dict


def main():
    parser = argparse.ArgumentParser(description="A股个股分析")
    parser.add_argument("code", nargs="?", default="600519", help="股票代码 (默认: 600519)")
    parser.add_argument("--save", action="store_true", help="保存 Markdown 报告到 reports/ 目录")
    parser.add_argument("--output-dir", default=None, help="自定义报告输出目录")
    args = parser.parse_args()

    result = asyncio.run(analyze_stock(args.code, save=args.save, output_dir=args.output_dir))
    print(json.dumps(_dataclass_to_dict(result), ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/report.py
content: #!/usr/bin/env python3
"""A股分析报告生成器 — 统一入口

用法:
  python3 scripts/report.py stock 600519           # 个股分析 + 生成报告
  python3 scripts/report.py market                  # 市场分析 + 生成报告
  python3 scripts/report.py stock 600519 --json     # 同时输出 JSON
  python3 scripts/report.py stock 600519 -o ./out   # 自定义输出目录
"""
import argparse
import asyncio
import json
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from src.index import analyze_stock, analyze_market, _dataclass_to_dict


def main():
    parser = argparse.ArgumentParser(
        prog="report",
        description="A股分析报告生成器",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    stock_parser = sub.add_parser("stock", help="个股分析报告")
    stock_parser.add_argument("code", help="股票代码，如 600519")
    stock_parser.add_argument("-o", "--output-dir", default=None, help="报告输出目录 (默认: reports/)")
    stock_parser.add_argument("--json", action="store_true", help="同时输出 JSON 到控制台")

    market_parser = sub.add_parser("market", help="市场分析报告")
    market_parser.add_argument("-o", "--output-dir", default=None, help="报告输出目录 (默认: reports/)")
    market_parser.add_argument("--json", action="store_true", help="同时输出 JSON 到控制台")

    args = parser.parse_args()

    if args.command == "stock":
        result = asyncio.run(analyze_stock(args.code, save=True, output_dir=args.output_dir))
    else:
        result = asyncio.run(analyze_market(save=True, output_dir=args.output_dir))

    if args.json:
        print(json.dumps(_dataclass_to_dict(result), ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: src/__init__.py
content: 


path: src/analyzer/market.py
content: from __future__ import annotations

import logging

from src.analyzer.prompts import MARKET_ANALYSIS_SYSTEM_PROMPT, MARKET_ANALYSIS_USER_PROMPT
from src.config import SkillConfig
from src.data.provider import MarketDataProvider
from src.llm.client import LLMClient
from src.models import MarketAnalysisResult, MarketOverview

logger = logging.getLogger(__name__)


class MarketAnalyzer:
    """市场分析引擎"""

    def __init__(
        self,
        data_provider: MarketDataProvider,
        llm_client: LLMClient,
        config: SkillConfig,
    ):
        self.data_provider = data_provider
        self.llm = llm_client
        self.config = config

    async def analyze(self) -> MarketAnalysisResult:
        """执行市场分析"""
        logger.info("[市场分析] 开始采集市场数据...")

        overview = await self.data_provider.get_market_overview()

        logger.info("[市场分析] 数据采集完成: 指数%d个, 涨%d/跌%d, 领涨%d/领跌%d板块",
                    len(overview.indices),
                    overview.statistics.up_count, overview.statistics.down_count,
                    len(overview.top_sectors), len(overview.bottom_sectors))
        logger.info("[市场分析] 开始调用 LLM 分析...")

        system_prompt = MARKET_ANALYSIS_SYSTEM_PROMPT
        user_prompt = self._build_user_prompt(overview)

        try:
            result_dict = await self.llm.analyze_json(system_prompt, user_prompt)
            result = self._parse_result(overview, result_dict)
            logger.info("[市场分析] LLM 分析完成: 情绪=%s", result.sentiment)
            return result
        except Exception as e:
            logger.error("市场分析 LLM 调用失败: %s", e)
            return MarketAnalysisResult(
                core_conclusion=f"分析失败: {e}",
                raw_report="",
            )

    def _build_user_prompt(self, overview: MarketOverview) -> str:
        """组装市场分析 Prompt"""
        indices_lines = []
        for idx in overview.indices:
            emoji = "🟢" if idx.change_pct > 0 else "🔴" if idx.change_pct < 0 else "⚪"
            indices_lines.append(
                f"- {emoji} {idx.name}: {idx.close:.2f} ({'+' if idx.change_pct > 0 else ''}{idx.change_pct:.2f}%)"
            )
        indices_text = "\n".join(indices_lines) if indices_lines else "暂无数据"

        top_lines = []
        for s in overview.top_sectors:
            top_lines.append(f"- {s.name}: +{s.change_pct:.2f}%")
        top_sectors_text = "\n".join(top_lines) if top_lines else "暂无数据"

        bottom_lines = []
        for s in overview.bottom_sectors:
            bottom_lines.append(f"- {s.name}: {s.change_pct:.2f}%")
        bottom_sectors_text = "\n".join(bottom_lines) if bottom_lines else "暂无数据"

        return MARKET_ANALYSIS_USER_PROMPT.format(
            indices_text=indices_text,
            up_count=overview.statistics.up_count,
            down_count=overview.statistics.down_count,
            flat_count=overview.statistics.flat_count,
            limit_up_count=overview.statistics.limit_up_count,
            limit_down_count=overview.statistics.limit_down_count,
            top_sectors_text=top_sectors_text,
            bottom_sectors_text=bottom_sectors_text,
        )

    def _parse_result(self, overview: MarketOverview, data: dict) -> MarketAnalysisResult:
        """解析 LLM 返回的结果"""
        from datetime import datetime

        return MarketAnalysisResult(
            date=datetime.now().strftime("%Y-%m-%d"),
            core_conclusion=str(data.get("core_conclusion", "")),
            indices=overview.indices,
            statistics=overview.statistics,
            top_sectors=overview.top_sectors,
            bottom_sectors=overview.bottom_sectors,
            sentiment=str(data.get("sentiment", "")),
            strategy=str(data.get("strategy", "")),
            raw_report=str(data.get("report", "")),
        )


path: src/analyzer/prompts.py
content: from __future__ import annotations

STOCK_ANALYSIS_SYSTEM_PROMPT = """你是一位专业的A股证券分析师，擅长基本面分析、技术面分析、资金面分析、筹码分析和舆情研判。

## 交易纪律（必须严格遵守）
1. 严禁追高：乖离率超过阈值（{bias_threshold}%）自动提示风险；强势趋势股自动放宽
2. 趋势交易：MA5 > MA10 > MA20 为多头排列，是趋势向好的重要信号
3. 精确点位：必须给出具体的买入价、止损价、目标价
4. 检查清单：每项条件以「满足/注意/不满足」标记
5. 新闻时效：只参考最近{news_max_age_days}天内的新闻

## 输出格式
请严格按照以下 JSON 格式输出分析结果：
```json
{{
  "core_conclusion": "一句话核心结论（包含操作方向和核心理由）",
  "score": 0-100的评分,
  "action": "买入/观望/卖出",
  "trend": "看多/震荡/看空",
  "buy_price": 买入价位（具体数字）,
  "stop_loss_price": 止损价位（具体数字）,
  "target_price": 目标价位（具体数字）,
  "checklist": [
    {{"condition": "条件描述", "status": "满足/注意/不满足", "detail": "具体说明"}},
    {{"condition": "条件描述", "status": "满足/注意/不满足", "detail": "具体说明"}}
  ],
  "risk_alerts": ["风险点1", "风险点2"],
  "positive_catalysts": ["利好1", "利好2"],
  "news_summaries": ["第1条资讯的1-2句话摘要", "第2条资讯的1-2句话摘要"],
  "strategy": "详细的买卖策略建议",
  "report": "完整的 Markdown 格式分析报告"
}}
```

## news_summaries 说明
- 按资讯原始顺序，为每条资讯生成1-2句话摘要
- 摘要应聚焦与该股票投资分析相关的核心信息（业绩、估值、事件影响、机构观点等）
- 忽略广告和无关内容

## 检查清单必须包含的项目
1. 多头排列（MA5 > MA10 > MA20）
2. 乖离率是否在安全范围
3. 成交量是否配合
4. 筹码集中度
5. 主力资金方向（如有数据）
6. 估值合理性（如有数据）
7. 舆情情绪方向

## 分析维度优先级
1. 基本面（财务数据、估值）— 决定中长期方向
2. 资金面（主力资金流向）— 决定短期动能
3. 技术面（均线、乖离率）— 决定入场时机
4. 筹码面（获利比例、集中度）— 判断支撑压力
5. 舆情面（新闻情绪）— 识别催化事件

## 重要提示
- 所有价格必须给出具体数字，不要用区间
- 分析必须基于提供的数据，不要编造
- 如基本面和资金面数据缺失，仅基于技术面和筹码面分析，并在结论中注明
- 必须在报告末尾加上「⚠️ 仅供参考，不构成投资建议。股市有风险，投资需谨慎。」
"""

STOCK_ANALYSIS_USER_PROMPT = """请分析以下 A 股股票：

## 股票基本信息
- 代码：{stock_code}
- 名称：{stock_name}
- 行业：{industry}
- 总市值：{market_cap}万元

## 实时行情
- 当前价：{price}
- 涨跌幅：{change_pct}%
- 成交量：{volume}手
- 成交额：{turnover}千元
- 最高：{high}  最低：{low}  开盘：{open_price}
- 振幅：{amplitude}%
- 换手率：{turnover_rate}%

## 估值数据
{valuation_text}

## 核心财务指标
{financial_text}

## 主力资金流向
{capital_flow_text}

## 技术指标
- MA5：{ma5}  MA10：{ma10}  MA20：{ma20}  MA60：{ma60}
- 多头排列：{bullish_alignment}
- 乖离率(BIAS)：{bias}%
- 量比：{volume_ratio}
- 近期趋势：{recent_trend}

## 筹码分布
- 获利比例：{profit_ratio}%
- 平均成本：{avg_cost}
- 集中度：{concentration}%
- 90%筹码价位：{profit_90_cost}  10%筹码价位：{profit_10_cost}

## 近期资讯（含正文，请阅读正文提取核心投资信息）
{news_text}

请基于以上数据进行全面分析，给出操作建议。重点关注基本面和资金面数据，结合技术面判断入场时机。资讯部分提供了正文内容，请自行从中提取与投资分析相关的核心信息（业绩、估值、事件影响等），如有研报观点，请重点参考机构评级和盈利预测。
"""

MARKET_ANALYSIS_SYSTEM_PROMPT = """你是一位专业的A股市场分析师，擅长大盘复盘和市场情绪研判。

## 分析要求
1. 对当日市场行情进行复盘总结
2. 分析主要指数表现
3. 解读板块涨跌背后的逻辑
4. 判断市场情绪和后续走势
5. 给出操作建议

## 输出格式
请严格按照以下 JSON 格式输出分析结果：
```json
{{
  "core_conclusion": "一句话核心结论（包含市场整体判断）",
  "sentiment": "偏多/中性/偏空",
  "strategy": "详细的操作建议",
  "report": "完整的 Markdown 格式市场复盘报告"
}}
```

## 重要提示
- 分析必须基于提供的数据，不要编造
- 必须在报告末尾加上「⚠️ 仅供参考，不构成投资建议。股市有风险，投资需谨慎。」
"""

MARKET_ANALYSIS_USER_PROMPT = """请对今日 A 股市场进行复盘分析：

## 主要指数
{indices_text}

## 市场统计
- 上涨：{up_count}家
- 下跌：{down_count}家
- 平盘：{flat_count}家
- 涨停：{limit_up_count}家
- 跌停：{limit_down_count}家

## 板块表现
### 领涨板块
{top_sectors_text}

### 领跌板块
{bottom_sectors_text}

请基于以上数据进行市场复盘分析。
"""


path: src/analyzer/stock.py
content: from __future__ import annotations

import asyncio
import logging
import re
from typing import Optional

import pandas as pd

from src.analyzer.prompts import STOCK_ANALYSIS_SYSTEM_PROMPT, STOCK_ANALYSIS_USER_PROMPT
from src.config import SkillConfig
from src.data.provider import MarketDataProvider
from src.llm.client import LLMClient
from src.models import (
    CapitalFlow,
    CheckItem,
    ChipDistribution,
    FinancialData,
    NewsItem,
    RealtimeQuote,
    StockAnalysisResult,
    StockInfo,
    TechnicalIndicators,
    Valuation,
)
from src.search.base import NewsSearchEngine

logger = logging.getLogger(__name__)


def calculate_technical_indicators(df: pd.DataFrame, bias_threshold: float = 5.0) -> TechnicalIndicators:
    """从日K线数据计算技术指标"""
    if df is None or df.empty or len(df) < 5:
        return TechnicalIndicators()

    close = df["close"]

    ma5 = round(float(close.rolling(5).mean().iloc[-1]), 2) if len(close) >= 5 else 0.0
    ma10 = round(float(close.rolling(10).mean().iloc[-1]), 2) if len(close) >= 10 else 0.0
    ma20 = round(float(close.rolling(20).mean().iloc[-1]), 2) if len(close) >= 20 else 0.0
    ma60 = round(float(close.rolling(60).mean().iloc[-1]), 2) if len(close) >= 60 else 0.0

    is_bullish = ma5 > ma10 > ma20 if ma5 and ma10 and ma20 else False

    current_price = float(close.iloc[-1])
    bias = 0.0
    if ma20 > 0:
        bias = round((current_price - ma20) / ma20 * 100, 2)

    volume_ratio = 0.0
    if "volume" in df.columns and len(df) >= 5:
        vol = df["volume"]
        avg_vol_5 = vol.rolling(5).mean().iloc[-1] if len(vol) >= 5 else 0
        if avg_vol_5 and avg_vol_5 > 0:
            volume_ratio = round(float(vol.iloc[-1]) / float(avg_vol_5), 2)

    recent_trend = "震荡"
    if is_bullish:
        recent_trend = "多头排列"
    elif ma5 < ma10 < ma20 if ma5 and ma10 and ma20 else False:
        recent_trend = "空头排列"

    return TechnicalIndicators(
        ma5=ma5,
        ma10=ma10,
        ma20=ma20,
        ma60=ma60,
        is_bullish_alignment=is_bullish,
        bias=bias,
        volume_ratio=volume_ratio,
        recent_trend=recent_trend,
    )


class StockAnalyzer:
    """个股分析引擎"""

    def __init__(
        self,
        data_provider: MarketDataProvider,
        llm_client: LLMClient,
        search_engines: list[NewsSearchEngine],
        config: SkillConfig,
    ):
        self.data_provider = data_provider
        self.llm = llm_client
        self.search_engines = search_engines
        self.config = config

    async def analyze(self, code: str) -> StockAnalysisResult:
        """执行个股分析"""
        logger.info("[个股分析] 开始采集数据: %s", code)

        stock_info = await self.data_provider.get_stock_info(code)
        logger.debug("[个股分析] 股票信息: %s(%s) 行业=%s", stock_info.name, stock_info.code, stock_info.industry)

        logger.info("[个股分析] 并行采集数据...")
        gather_start = asyncio.get_event_loop().time()

        daily_data, realtime, chip, capital_flow, valuation, financial, news = await asyncio.gather(
            self.data_provider.get_daily_data(code, 120),
            self.data_provider.get_realtime_quote(code),
            self._get_chip(code),
            self._get_capital_flow(code),
            self._get_valuation(code),
            self._get_financial_data(code),
            self._search_news(stock_info.name, stock_info.code),
            return_exceptions=True,
        )

        gather_elapsed = asyncio.get_event_loop().time() - gather_start
        logger.info("[个股分析] 并行采集完成 耗时%.2fs", gather_elapsed)

        if isinstance(daily_data, Exception):
            logger.warning("[个股分析] 日K线采集异常: %s", daily_data)
            daily_data = pd.DataFrame()
        if isinstance(realtime, Exception):
            logger.warning("[个股分析] 实时行情采集异常: %s", realtime)
            realtime = RealtimeQuote()
        if isinstance(chip, Exception):
            logger.warning("[个股分析] 筹码分布采集异常: %s", chip)
            chip = None
        if isinstance(capital_flow, Exception):
            logger.warning("[个股分析] 主力资金采集异常: %s", capital_flow)
            capital_flow = None
        if isinstance(valuation, Exception):
            logger.warning("[个股分析] 估值数据采集异常: %s", valuation)
            valuation = None
        if isinstance(financial, Exception):
            logger.warning("[个股分析] 财务数据采集异常: %s", financial)
            financial = None
        if isinstance(news, Exception):
            logger.warning("[个股分析] 新闻搜索异常: %s", news)
            news = []

        logger.debug("[个股分析] 日K线: %d条", len(daily_data) if daily_data is not None else 0)
        logger.debug("[个股分析] 实时行情: price=%.2f", realtime.price if isinstance(realtime, RealtimeQuote) else 0)
        logger.debug("[个股分析] 筹码: %s", "有" if chip else "无")
        logger.debug("[个股分析] 主力资金: %s", "有" if capital_flow else "无")
        logger.debug("[个股分析] 估值: %s", "有" if valuation else "无")
        logger.debug("[个股分析] 财务: %s", "有" if financial else "无")
        logger.debug("[个股分析] 新闻: %d条", len(news) if isinstance(news, list) else 0)

        if isinstance(news, list):
            news = news[:5]

        tech = calculate_technical_indicators(daily_data, self.config.bias_threshold)
        logger.debug("[个股分析] 技术指标: MA5=%.2f MA20=%.2f 多头排列=%s 乖离=%.2f%%",
                     tech.ma5, tech.ma20, tech.is_bullish_alignment, tech.bias)

        logger.info("[个股分析] 数据采集完成，开始调用 LLM 分析: %s", code)

        system_prompt = STOCK_ANALYSIS_SYSTEM_PROMPT.format(
            bias_threshold=self.config.bias_threshold,
            news_max_age_days=self.config.news_max_age_days,
        )
        user_prompt = self._build_user_prompt(stock_info, realtime, tech, chip, capital_flow, valuation, financial, news)

        try:
            result_dict = await self.llm.analyze_json(system_prompt, user_prompt)
            result = self._parse_result(stock_info, result_dict, news)

            summaries = result_dict.get("news_summaries", [])
            if isinstance(summaries, list):
                for i, item in enumerate(news):
                    if i < len(summaries) and summaries[i]:
                        item.snippet = str(summaries[i])

            result.stock_info = stock_info
            result.realtime = realtime
            result.tech = tech
            result.chip = chip
            result.capital_flow = capital_flow
            result.valuation = valuation
            result.financial = financial
            result.news = news
            logger.info("[个股分析] LLM 分析完成: %s → %s (评分%d, %s)",
                        code, result.action, result.score, result.trend)
            return result
        except Exception as e:
            logger.error("个股分析 LLM 调用失败 %s: %s", code, e)
            return StockAnalysisResult(
                stock_code=code,
                stock_name=stock_info.name,
                core_conclusion=f"分析失败: {e}",
                raw_report="",
                stock_info=stock_info,
                realtime=realtime,
                tech=tech,
                chip=chip,
                capital_flow=capital_flow,
                valuation=valuation,
                financial=financial,
                news=news,
            )

    async def _get_chip(self, code: str) -> Optional[ChipDistribution]:
        """获取筹码分布"""
        if not self.config.enable_chip:
            return None
        try:
            return await self.data_provider.get_chip_distribution(code)
        except Exception as e:
            logger.warning("获取筹码分布失败 %s: %s", code, e)
            return None

    async def _get_capital_flow(self, code: str) -> Optional[CapitalFlow]:
        try:
            return await self.data_provider.get_capital_flow(code)
        except Exception as e:
            logger.warning("获取主力资金流向失败 %s: %s", code, e)
            return None

    async def _get_valuation(self, code: str) -> Optional[Valuation]:
        try:
            return await self.data_provider.get_valuation(code)
        except Exception as e:
            logger.warning("获取估值数据失败 %s: %s", code, e)
            return None

    async def _get_financial_data(self, code: str) -> Optional[FinancialData]:
        try:
            return await self.data_provider.get_financial_data(code)
        except Exception as e:
            logger.warning("获取财务数据失败 %s: %s", code, e)
            return None

    async def _search_news(self, stock_name: str, stock_code: str) -> list[NewsItem]:
        """搜索新闻舆情"""
        if not self.search_engines:
            logger.debug("[个股分析] 未配置搜索引擎，跳过新闻搜索")
            return []

        for engine in self.search_engines:
            try:
                if engine.name == "MiaoxiangSearch":
                    query = f"{stock_name} 最新新闻 研报"
                else:
                    query = f"{stock_name} {stock_code} 股票 最新消息"
                results = await engine.search(query, self.config.news_max_age_days)
                if results:
                    logger.debug("[个股分析] 搜索引擎 %s 返回 %d 条新闻", engine.name, len(results))
                    return results
            except Exception as e:
                logger.warning("搜索引擎 %s 失败: %s", engine.name, e)
                continue
        logger.debug("[个股分析] 所有搜索引擎均未返回新闻")
        return []

    def _build_user_prompt(
        self,
        stock_info: StockInfo,
        realtime: RealtimeQuote,
        tech: TechnicalIndicators,
        chip: Optional[ChipDistribution],
        capital_flow: Optional[CapitalFlow],
        valuation: Optional[Valuation],
        financial: Optional[FinancialData],
        news: list[NewsItem],
    ) -> str:
        news_text = "暂无资讯数据"
        if news:
            limited = news[:5]
            lines = []
            for i, item in enumerate(limited, 1):
                type_tag = {"report": "[研报]", "announcement": "[公告]"}.get(item.info_type, "[新闻]")
                source_tag = f" [{item.source}]" if item.source else ""
                text = item.content or item.snippet
                if text and len(text) > 2000:
                    text = text[:2000] + "..."
                lines.append(f"{i}. {type_tag} [{item.date}] {item.title}{source_tag}\n{text}")
            news_text = "\n\n".join(lines)

        chip_info = chip or ChipDistribution()

        valuation_text = "暂无估值数据"
        if valuation:
            parts = []
            if valuation.pe_ttm > 0:
                parts.append(f"- 市盈率(TTM)：{valuation.pe_ttm:.1f}")
            if valuation.pb > 0:
                parts.append(f"- 市净率：{valuation.pb:.2f}")
            if valuation.pe_percentile > 0:
                parts.append(f"- PE历史分位：{valuation.pe_percentile:.1f}%")
            if valuation.pb_percentile > 0:
                parts.append(f"- PB历史分位：{valuation.pb_percentile:.1f}%")
            valuation_text = "\n".join(parts) if parts else "暂无估值数据"

        financial_text = "暂无财务数据"
        if financial:
            parts = []
            if financial.net_profit != 0:
                parts.append(f"- 归母净利润：{financial.net_profit:.0f}元")
            if financial.revenue != 0:
                parts.append(f"- 营业收入：{financial.revenue:.0f}元")
            if financial.net_profit_yoy != 0:
                parts.append(f"- 净利润同比增长：{financial.net_profit_yoy:.2f}%")
            if financial.revenue_yoy != 0:
                parts.append(f"- 营收同比增长：{financial.revenue_yoy:.2f}%")
            if financial.roe != 0:
                parts.append(f"- 净资产收益率(ROE)：{financial.roe:.2f}%")
            if financial.gross_margin != 0:
                parts.append(f"- 毛利率：{financial.gross_margin:.2f}%")
            if financial.debt_ratio != 0:
                parts.append(f"- 资产负债率：{financial.debt_ratio:.2f}%")
            if financial.forecast_profit != 0:
                parts.append(f"- 预测净利润中值：{financial.forecast_profit:.0f}元")
            if financial.forecast_growth != 0:
                parts.append(f"- 预测增长率：{financial.forecast_growth:.2f}%")
            if financial.institution_holding_pct != 0:
                parts.append(f"- 机构持股比例：{financial.institution_holding_pct:.2f}%")
            financial_text = "\n".join(parts) if parts else "暂无财务数据"

        capital_flow_text = "暂无主力资金数据"
        if capital_flow:
            parts = []
            if capital_flow.super_large_net != 0:
                parts.append(f"- 超大单净额：{capital_flow.super_large_net:.0f}元")
            if capital_flow.large_net != 0:
                parts.append(f"- 大单净额：{capital_flow.large_net:.0f}元")
            if capital_flow.medium_net != 0:
                parts.append(f"- 中单净额：{capital_flow.medium_net:.0f}元")
            if capital_flow.small_net != 0:
                parts.append(f"- 小单净额：{capital_flow.small_net:.0f}元")
            if capital_flow.ddx != 0:
                parts.append(f"- DDX(大单动向)：{capital_flow.ddx:.2f}")
            if capital_flow.ddy != 0:
                parts.append(f"- DDY(筹码集中度变化)：{capital_flow.ddy:.2f}")
            if capital_flow.ddz != 0:
                parts.append(f"- DDZ(资金强度)：{capital_flow.ddz:.2f}")
            capital_flow_text = "\n".join(parts) if parts else "暂无主力资金数据"

        return STOCK_ANALYSIS_USER_PROMPT.format(
            stock_code=stock_info.code,
            stock_name=stock_info.name,
            industry=stock_info.industry or "未知",
            market_cap=f"{stock_info.market_cap:.0f}" if stock_info.market_cap else "未知",
            price=realtime.price or "未知",
            change_pct=f"{realtime.change_pct:.2f}" if realtime.change_pct else "0.00",
            volume=f"{realtime.volume:.0f}" if realtime.volume else "0",
            turnover=f"{realtime.turnover:.0f}" if realtime.turnover else "0",
            high=realtime.high or "未知",
            low=realtime.low or "未知",
            open_price=realtime.open or "未知",
            amplitude=f"{realtime.amplitude:.2f}" if realtime.amplitude else "0.00",
            turnover_rate=f"{realtime.turnover_rate:.2f}" if realtime.turnover_rate else "0.00",
            valuation_text=valuation_text,
            financial_text=financial_text,
            capital_flow_text=capital_flow_text,
            ma5=tech.ma5 or "未知",
            ma10=tech.ma10 or "未知",
            ma20=tech.ma20 or "未知",
            ma60=tech.ma60 or "未知",
            bullish_alignment="是 ✓" if tech.is_bullish_alignment else "否 ✗",
            bias=f"{tech.bias:.2f}" if tech.bias else "0.00",
            volume_ratio=f"{tech.volume_ratio:.2f}" if tech.volume_ratio else "0.00",
            recent_trend=tech.recent_trend or "未知",
            profit_ratio=f"{chip_info.profit_ratio:.1f}" if chip_info.profit_ratio else "未知",
            avg_cost=f"{chip_info.avg_cost:.2f}" if chip_info.avg_cost else "未知",
            concentration=f"{chip_info.concentration:.2f}" if chip_info.concentration else "未知",
            profit_90_cost=f"{chip_info.profit_90_cost:.2f}" if chip_info.profit_90_cost else "未知",
            profit_10_cost=f"{chip_info.profit_10_cost:.2f}" if chip_info.profit_10_cost else "未知",
            news_text=news_text,
        )

    def _extract_name_from_news(self, news: list[NewsItem], code: str) -> str:
        """从新闻标题中提取股票名称"""
        for item in news:
            m = re.search(r'([^\s（(]+?)（?' + re.escape(code) + r'）?', item.title)
            if m:
                name = m.group(1).strip()
                if name and len(name) >= 2 and not name.isdigit():
                    return name
        return ""

    def _parse_result(self, stock_info: StockInfo, data: dict, news: list[NewsItem] = None) -> StockAnalysisResult:
        """解析 LLM 返回的结果"""
        checklist = []
        for item in data.get("checklist", []):
            if isinstance(item, dict):
                checklist.append(CheckItem(
                    condition=str(item.get("condition", "")),
                    status=str(item.get("status", "")),
                    detail=str(item.get("detail", "")),
                ))

        buy_price = data.get("buy_price")
        stop_loss = data.get("stop_loss_price")
        target = data.get("target_price")

        stock_name = stock_info.name
        if (not stock_name or stock_name == stock_info.code) and news:
            extracted = self._extract_name_from_news(news, stock_info.code)
            if extracted:
                stock_name = extracted

        return StockAnalysisResult(
            stock_code=stock_info.code,
            stock_name=stock_name,
            core_conclusion=str(data.get("core_conclusion", "")),
            score=int(data.get("score", 0) or 0),
            action=str(data.get("action", "")),
            trend=str(data.get("trend", "")),
            buy_price=float(buy_price) if buy_price is not None else None,
            stop_loss_price=float(stop_loss) if stop_loss is not None else None,
            target_price=float(target) if target is not None else None,
            checklist=checklist,
            risk_alerts=[str(a) for a in data.get("risk_alerts", [])],
            positive_catalysts=[str(c) for c in data.get("positive_catalysts", [])],
            strategy=str(data.get("strategy", "")),
            raw_report=str(data.get("report", "")),
        )


path: src/config.py
content: import logging
import os
from typing import Union
from dataclasses import dataclass, field
from dotenv import load_dotenv

load_dotenv()


def setup_logging(level: Union[str, int, None] = None) -> None:
    """初始化项目日志配置

    Args:
        level: 日志级别，支持 "DEBUG"/"INFO"/"WARNING"/"ERROR" 或 logging 常量。
               默认从环境变量 LOG_LEVEL 读取，未设置时默认 INFO。
    """
    if level is None:
        level = os.getenv("LOG_LEVEL", "INFO")
    if isinstance(level, str):
        level = getattr(logging, level.upper(), logging.INFO)

    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
        datefmt="%H:%M:%S",
    )


@dataclass
class SkillConfig:
    llm_base_url: str = field(default_factory=lambda: os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"))
    llm_api_key: str = field(default_factory=lambda: os.getenv("LLM_API_KEY", ""))
    llm_model: str = field(default_factory=lambda: os.getenv("LLM_MODEL", "deepseek-chat"))

    serpapi_key: str = field(default_factory=lambda: os.getenv("SERPAPI_KEY", ""))
    tavily_key: str = field(default_factory=lambda: os.getenv("TAVILY_KEY", ""))
    brave_key: str = field(default_factory=lambda: os.getenv("BRAVE_KEY", ""))
    bocha_key: str = field(default_factory=lambda: os.getenv("BOCHA_KEY", ""))

    mx_apikey: str = field(default_factory=lambda: os.getenv("MX_APIKEY", ""))

    bias_threshold: float = field(default_factory=lambda: float(os.getenv("BIAS_THRESHOLD", "5.0")))
    news_max_age_days: int = field(default_factory=lambda: int(os.getenv("NEWS_MAX_AGE_DAYS", "3")))
    enable_chip: bool = field(default_factory=lambda: os.getenv("ENABLE_CHIP", "true").lower() == "true")

    @property
    def has_search_engine(self) -> bool:
        return bool(self.serpapi_key or self.tavily_key or self.brave_key or self.bocha_key)

    def validate(self) -> list[str]:
        errors = []
        if not self.llm_api_key:
            errors.append("LLM_API_KEY 未配置")
        if not self.llm_base_url:
            errors.append("LLM_BASE_URL 未配置")
        if not self.llm_model:
            errors.append("LLM_MODEL 未配置")
        return errors


path: src/data/akshare_provider.py
content: from __future__ import annotations

import asyncio
import logging
import time
from datetime import datetime, timedelta
from typing import Optional

import pandas as pd

from src.data.provider import MarketDataProvider
from src.data.utils import (
    RealtimeCache,
    calc_market_stats,
    calculate_chip_from_daily,
    enforce_rate_limit,
    safe_float,
)
from src.models import (
    ChipDistribution,
    IndexData,
    MarketOverview,
    MarketStatistics,
    RealtimeQuote,
    SectorData,
    StockInfo,
)

logger = logging.getLogger(__name__)

_INDICES_MAP = {
    "sh000001": "上证指数",
    "sz399001": "深证成指",
    "sz399006": "创业板指",
}


class AkShareProvider(MarketDataProvider):

    def __init__(self, sleep_min: float = 1.5, sleep_max: float = 3.0):
        self.sleep_min = sleep_min
        self.sleep_max = sleep_max
        self._realtime_cache = RealtimeCache()

    def _enforce_rate_limit(self) -> None:
        enforce_rate_limit(self.sleep_min, self.sleep_max)

    def _run_sync(self, func, *args, **kwargs):
        return asyncio.to_thread(func, *args, **kwargs)

    def _get_realtime_df(self):
        import akshare as ak

        cached = self._realtime_cache.get()
        if cached is not None:
            logger.debug("AkShare 全市场实时行情缓存命中")
            return cached
        self._enforce_rate_limit()
        logger.debug("AkShare 调用 ak.stock_zh_a_spot_em()...")
        df = ak.stock_zh_a_spot_em()
        logger.debug("AkShare 全市场实时行情获取完成: %d行 %d列", len(df), len(df.columns))
        self._realtime_cache.set(df)
        logger.debug("AkShare 全市场实时行情已缓存")
        return df

    async def get_stock_info(self, code: str) -> StockInfo:
        start = time.monotonic()
        code = self.normalize_code(code)
        logger.debug("AkShare 获取股票信息: code=%s", code)
        try:
            def _fetch():
                import akshare as ak
                self._enforce_rate_limit()
                try:
                    info_df = ak.stock_individual_info_em(symbol=code)
                except Exception:
                    info_df = None
                spot_df = self._get_realtime_df()
                return info_df, spot_df

            info_df, spot_df = await self._run_sync(_fetch)

            name = code
            industry = ""
            market_cap = 0.0
            list_date = ""

            if info_df is not None and not info_df.empty:
                info_dict = {}
                if "item" in info_df.columns and "value" in info_df.columns:
                    for _, r in info_df.iterrows():
                        info_dict[str(r["item"])] = str(r["value"])
                name = info_dict.get("股票简称", code)
                industry = info_dict.get("行业", "")
                list_date = info_dict.get("上市时间", "")

            if spot_df is not None and not spot_df.empty:
                row = spot_df[spot_df["代码"] == code]
                if not row.empty:
                    row = row.iloc[0]
                    market_cap = safe_float(row.get("总市值", 0))
                    if not name or name == code:
                        name = str(row.get("名称", code))

            logger.debug("AkShare 股票信息: name=%s, industry=%s, market_cap=%.0f", name, industry, market_cap)
            return StockInfo(
                code=code, name=name, industry=industry,
                market_cap=market_cap, list_date=list_date,
            )
        except Exception as e:
            logger.warning("AkShareProvider 获取股票信息失败 %s: %s", code, e)
            return StockInfo(code=code, name=code)
        finally:
            elapsed = time.monotonic() - start
            logger.debug("AkShare get_stock_info 完成 耗时%.2fs", elapsed)

    async def get_daily_data(self, code: str, days: int = 120) -> pd.DataFrame:
        start = time.monotonic()
        code = self.normalize_code(code)
        try:
            def _fetch():
                import akshare as ak
                self._enforce_rate_limit()
                end_date = datetime.now().strftime("%Y%m%d")
                start_date = (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
                logger.debug("AkShare 获取日K线: code=%s, days=%d, date_range=%s~%s", code, days, start_date, end_date)
                return ak.stock_zh_a_hist(
                    symbol=code,
                    period="daily",
                    start_date=start_date,
                    end_date=end_date,
                    adjust="qfq",
                )

            df = await self._run_sync(_fetch)
            if df is None or df.empty:
                return pd.DataFrame()

            logger.debug("AkShare 日K线获取完成: %d条原始数据", len(df))

            column_mapping = {
                "日期": "trade_date",
                "开盘": "open",
                "收盘": "close",
                "最高": "high",
                "最低": "low",
                "成交量": "volume",
                "涨跌幅": "change_pct",
            }
            df = df.rename(columns=column_mapping)
            df["trade_date"] = pd.to_datetime(df["trade_date"])
            df = df.sort_values("trade_date").reset_index(drop=True)
            result = df.tail(days).reset_index(drop=True)
            logger.debug("AkShare 日K线处理完成: %d条最终数据", len(result))
            return result
        except Exception as e:
            logger.error("AkShareProvider 获取日K线数据失败 %s: %s", code, e)
            return pd.DataFrame()
        finally:
            elapsed = time.monotonic() - start
            logger.debug("AkShare get_daily_data 完成 耗时%.2fs", elapsed)

    async def get_realtime_quote(self, code: str) -> RealtimeQuote:
        start = time.monotonic()
        code = self.normalize_code(code)
        logger.debug("AkShare 获取实时行情: code=%s", code)
        try:
            df = await self._run_sync(self._get_realtime_df)
            if df is None or df.empty:
                return RealtimeQuote()

            row = df[df["代码"] == code]
            if row.empty:
                logger.debug("AkShare 实时行情未找到股票: code=%s", code)
                return RealtimeQuote()
            row = row.iloc[0]

            prev_close = safe_float(row.get("昨收", 0))
            high = safe_float(row.get("最高", 0))
            low = safe_float(row.get("最低", 0))
            amplitude = safe_float(row.get("振幅", 0))
            if not amplitude and prev_close > 0:
                amplitude = (high - low) / prev_close * 100

            price = safe_float(row.get("最新价"))
            change_pct = safe_float(row.get("涨跌幅"))
            logger.debug("AkShare 实时行情: price=%.2f, change_pct=%.2f%%", price, change_pct)

            return RealtimeQuote(
                price=price,
                change_pct=change_pct,
                change_amt=safe_float(row.get("涨跌额")),
                volume=safe_float(row.get("成交量")),
                turnover=safe_float(row.get("成交额")),
                high=high,
                low=low,
                open=safe_float(row.get("今开")),
                prev_close=prev_close,
                amplitude=amplitude,
                turnover_rate=safe_float(row.get("换手率")),
            )
        except Exception as e:
            logger.error("AkShareProvider 获取实时行情失败 %s: %s", code, e)
            return RealtimeQuote()
        finally:
            elapsed = time.monotonic() - start
            logger.debug("AkShare get_realtime_quote 完成 耗时%.2fs", elapsed)

    async def get_chip_distribution(self, code: str) -> Optional[ChipDistribution]:
        start = time.monotonic()
        code = self.normalize_code(code)
        logger.debug("AkShare 获取筹码分布: code=%s", code)
        try:
            def _fetch():
                import akshare as ak
                self._enforce_rate_limit()
                return ak.stock_cyq_em(symbol=code)

            df = await self._run_sync(_fetch)
            if df is None or df.empty:
                logger.debug("AkShare 筹码接口无数据，回退K线估算: code=%s", code)
                return await self._calculate_chip_from_daily(code)

            logger.debug("AkShare 筹码接口返回数据: %d行", len(df))
            latest = df.iloc[-1]
            return ChipDistribution(
                profit_ratio=safe_float(latest.get("获利比例")) * 100,
                avg_cost=safe_float(latest.get("平均成本")),
                concentration=safe_float(latest.get("90集中度")) * 100,
                profit_90_cost=safe_float(latest.get("90成本-高")),
                profit_10_cost=safe_float(latest.get("90成本-低")),
            )
        except Exception as e:
            logger.warning("AkShareProvider 筹码接口失败 %s: %s，尝试K线估算", code, e)
            return await self._calculate_chip_from_daily(code)
        finally:
            elapsed = time.monotonic() - start
            logger.debug("AkShare get_chip_distribution 完成 耗时%.2fs", elapsed)

    async def _calculate_chip_from_daily(self, code: str) -> Optional[ChipDistribution]:
        try:
            df = await self.get_daily_data(code, 90)
        except Exception:
            return None

        logger.debug("AkShare K线估算筹码: code=%s, 数据%d条", code, len(df))
        return calculate_chip_from_daily(df)

    async def get_market_overview(self) -> MarketOverview:
        indices = await self.get_indices()
        statistics = await self.get_market_statistics()
        top_sectors, bottom_sectors = await self.get_sector_rankings()
        return MarketOverview(
            indices=indices,
            statistics=statistics,
            top_sectors=top_sectors,
            bottom_sectors=bottom_sectors,
        )

    async def get_sector_rankings(self) -> tuple[list[SectorData], list[SectorData]]:
        start = time.monotonic()
        try:
            def _fetch_em():
                import akshare as ak
                self._enforce_rate_limit()
                return ak.stock_board_industry_name_em()

            logger.debug("AkShare 获取板块排名(东财)...")
            df = await self._run_sync(_fetch_em)
            if df is not None and not df.empty:
                change_col = "涨跌幅"
                name_col = "板块名称"
                df[change_col] = pd.to_numeric(df[change_col], errors="coerce")
                df = df.dropna(subset=[change_col])
                top = df.nlargest(5, change_col)
                bottom = df.nsmallest(5, change_col)
                top_sectors = [
                    SectorData(name=str(r[name_col]), change_pct=float(r[change_col]))
                    for _, r in top.iterrows()
                ]
                bottom_sectors = [
                    SectorData(name=str(r[name_col]), change_pct=float(r[change_col]))
                    for _, r in bottom.iterrows()
                ]
                logger.debug("AkShare 东财板块排名: 领涨%d个, 领跌%d个", len(top_sectors), len(bottom_sectors))
                elapsed = time.monotonic() - start
                logger.debug("AkShare get_sector_rankings 完成 耗时%.2fs", elapsed)
                return top_sectors, bottom_sectors
        except Exception as e:
            logger.warning("AkShareProvider 东财板块接口失败: %s，尝试新浪接口", e)

        try:
            def _fetch_sina():
                import akshare as ak
                self._enforce_rate_limit()
                return ak.stock_sector_spot(indicator="行业")

            logger.debug("AkShare 获取板块排名(新浪)...")
            df = await self._run_sync(_fetch_sina)
            if df is not None and not df.empty:
                change_col = "涨跌幅"
                name_col = "板块"
                df[change_col] = pd.to_numeric(df[change_col], errors="coerce")
                df = df.dropna(subset=[change_col])
                top = df.nlargest(5, change_col)
                bottom = df.nsmallest(5, change_col)
                top_sectors = [
                    SectorData(name=str(r[name_col]), change_pct=float(r[change_col]))
                    for _, r in top.iterrows()
                ]
                bottom_sectors = [
                    SectorData(name=str(r[name_col]), change_pct=float(r[change_col]))
                    for _, r in bottom.iterrows()
                ]
                logger.debug("AkShare 新浪板块排名: 领涨%d个, 领跌%d个", len(top_sectors), len(bottom_sectors))
                elapsed = time.monotonic() - start
                logger.debug("AkShare get_sector_rankings 完成 耗时%.2fs", elapsed)
                return top_sectors, bottom_sectors
        except Exception as e:
            logger.warning("AkShareProvider 新浪板块接口也失败: %s", e)

        elapsed = time.monotonic() - start
        logger.debug("AkShare get_sector_rankings 完成 耗时%.2fs", elapsed)
        return [], []

    async def get_market_statistics(self) -> MarketStatistics:
        start = time.monotonic()
        try:
            logger.debug("AkShare 获取市场统计(东财)...")
            df = await self._run_sync(self._get_realtime_df)
            if df is not None and not df.empty:
                stats = calc_market_stats(df, self)
                logger.debug("AkShare 东财市场统计: 涨%d 跌%d 平%d 涨停%d 跌停%d", stats.up_count, stats.down_count, stats.flat_count, stats.limit_up_count, stats.limit_down_count)
                elapsed = time.monotonic() - start
                logger.debug("AkShare get_market_statistics 完成 耗时%.2fs", elapsed)
                return stats
        except Exception as e:
            logger.warning("AkShareProvider 东财市场统计失败: %s，尝试新浪接口", e)

        try:
            def _fetch_sina():
                import akshare as ak
                self._enforce_rate_limit()
                return ak.stock_zh_a_spot()

            logger.debug("AkShare 获取市场统计(新浪)...")
            df = await self._run_sync(_fetch_sina)
            if df is not None and not df.empty:
                stats = calc_market_stats(df, self)
                logger.debug("AkShare 新浪市场统计: 涨%d 跌%d 平%d 涨停%d 跌停%d", stats.up_count, stats.down_count, stats.flat_count, stats.limit_up_count, stats.limit_down_count)
                elapsed = time.monotonic() - start
                logger.debug("AkShare get_market_statistics 完成 耗时%.2fs", elapsed)
                return stats
        except Exception as e:
            logger.warning("AkShareProvider 新浪市场统计也失败: %s", e)

        elapsed = time.monotonic() - start
        logger.debug("AkShare get_market_statistics 完成 耗时%.2fs", elapsed)
        return MarketStatistics()

    async def get_indices(self) -> list[IndexData]:
        start = time.monotonic()
        try:
            def _fetch():
                import akshare as ak
                self._enforce_rate_limit()
                return ak.stock_zh_index_spot_sina()

            logger.debug("AkShare 获取指数数据(新浪)...")
            df = await self._run_sync(_fetch)
            if df is None or df.empty:
                return []

            results: list[IndexData] = []
            for idx_code, idx_name in _INDICES_MAP.items():
                row = df[df["代码"] == idx_code]
                if row.empty:
                    continue
                item = row.iloc[0]

                close_val = safe_float(item.get("最新价"))
                prev_close = safe_float(item.get("昨收"))
                change_amt = safe_float(item.get("涨跌额"))
                change_pct = safe_float(item.get("涨跌幅"))

                results.append(IndexData(
                    name=idx_name,
                    code=idx_code,
                    close=close_val,
                    change_pct=change_pct,
                    change_amt=change_amt,
                ))
            logger.debug("AkShare 指数数据: %d个", len(results))
            elapsed = time.monotonic() - start
            logger.debug("AkShare get_indices 完成 耗时%.2fs", elapsed)
            return results
        except Exception as e:
            logger.warning("AkShareProvider 获取指数数据失败: %s", e)
            elapsed = time.monotonic() - start
            logger.debug("AkShare get_indices 完成 耗时%.2fs", elapsed)
            return []


path: src/data/efinance_provider.py
content: from __future__ import annotations

import asyncio
import logging
import time
from datetime import datetime, timedelta
from typing import Optional

import pandas as pd

from src.data.provider import MarketDataProvider
from src.data.utils import RealtimeCache, calc_market_stats, enforce_rate_limit, safe_float
from src.models import (
    ChipDistribution,
    IndexData,
    MarketOverview,
    MarketStatistics,
    RealtimeQuote,
    SectorData,
    StockInfo,
)

logger = logging.getLogger(__name__)

_INDICES_MAP = {
    "000001": "上证指数",
    "399001": "深证成指",
    "399006": "创业板指",
}


class EfinanceProvider(MarketDataProvider):

    def __init__(self, sleep_min: float = 1.5, sleep_max: float = 3.0):
        self.sleep_min = sleep_min
        self.sleep_max = sleep_max
        self._realtime_cache = RealtimeCache()

    def _enforce_rate_limit(self) -> None:
        enforce_rate_limit(self.sleep_min, self.sleep_max)

    def _run_sync(self, func, *args, **kwargs):
        return asyncio.to_thread(func, *args, **kwargs)

    def _get_realtime_df(self):
        import efinance as ef

        cached = self._realtime_cache.get()
        if cached is not None:
            logger.debug("Efinance 全市场实时行情缓存命中")
            return cached
        self._enforce_rate_limit()
        logger.debug("Efinance 调用 ef.stock.get_realtime_quotes()...")
        df = ef.stock.get_realtime_quotes()
        logger.debug("Efinance 全市场实时行情获取完成: %d行 %d列", len(df), len(df.columns))
        self._realtime_cache.set(df)
        logger.debug("Efinance 全市场实时行情已缓存")
        return df

    async def get_stock_info(self, code: str) -> StockInfo:
        start = time.monotonic()
        code = self.normalize_code(code)
        logger.debug("Efinance 获取股票信息: code=%s", code)
        try:
            def _fetch():
                import efinance as ef
                self._enforce_rate_limit()
                info = ef.stock.get_base_info(code)
                df = self._get_realtime_df()
                return info, df

            info, df = await self._run_sync(_fetch)

            name = code
            industry = ""
            market_cap = 0.0

            if info is not None:
                if isinstance(info, pd.Series):
                    info = info.to_dict()
                elif isinstance(info, pd.DataFrame) and not info.empty:
                    info = info.iloc[0].to_dict()
                else:
                    info = {}
                name = str(info.get("股票名称", code))
                industry = str(info.get("所处行业", ""))

            if df is not None and not df.empty:
                code_col = "股票代码" if "股票代码" in df.columns else "code"
                row = df[df[code_col].astype(str) == code]
                if not row.empty:
                    row = row.iloc[0]
                    mv_col = "总市值" if "总市值" in df.columns else "total_mv"
                    market_cap = safe_float(row.get(mv_col, 0))
                    if not name or name == code:
                        n_col = "股票名称" if "股票名称" in df.columns else "name"
                        name = str(row.get(n_col, code))

            logger.debug("Efinance 股票信息: name=%s, industry=%s, market_cap=%.0f", name, industry, market_cap)
            logger.debug("Efinance get_stock_info 完成 耗时%.2fs", time.monotonic() - start)
            return StockInfo(code=code, name=name, industry=industry, market_cap=market_cap)
        except Exception as e:
            logger.warning("EfinanceProvider 获取股票信息失败 %s: %s", code, e)
            return StockInfo(code=code, name=code)

    async def get_daily_data(self, code: str, days: int = 120) -> pd.DataFrame:
        start = time.monotonic()
        code = self.normalize_code(code)
        end_date = datetime.now().strftime("%Y%m%d")
        start_date = (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
        logger.debug("Efinance 获取日K线: code=%s, days=%d, date_range=%s~%s", code, days, start_date, end_date)
        try:
            def _fetch():
                import efinance as ef
                self._enforce_rate_limit()
                return ef.stock.get_quote_history(
                    stock_codes=code,
                    beg=start_date,
                    end=end_date,
                    klt=101,
                    fqt=1,
                )

            df = await self._run_sync(_fetch)
            logger.debug("Efinance 日K线获取完成: %d条原始数据", len(df) if df is not None and not df.empty else 0)
            if df is None or df.empty:
                return pd.DataFrame()

            column_mapping = {
                "日期": "trade_date",
                "开盘": "open",
                "收盘": "close",
                "最高": "high",
                "最低": "low",
                "成交量": "volume",
                "涨跌幅": "change_pct",
            }
            df = df.rename(columns=column_mapping)
            df["trade_date"] = pd.to_datetime(df["trade_date"])
            df = df.sort_values("trade_date").reset_index(drop=True)
            result = df.tail(days).reset_index(drop=True)
            logger.debug("Efinance 日K线处理完成: %d条最终数据", len(result))
            logger.debug("Efinance get_daily_data 完成 耗时%.2fs", time.monotonic() - start)
            return result
        except Exception as e:
            logger.error("EfinanceProvider 获取日K线数据失败 %s: %s", code, e)
            return pd.DataFrame()

    async def get_realtime_quote(self, code: str) -> RealtimeQuote:
        start = time.monotonic()
        code = self.normalize_code(code)
        logger.debug("Efinance 获取实时行情: code=%s", code)
        try:
            df = await self._run_sync(self._get_realtime_df)
            if df is None or df.empty:
                return RealtimeQuote()

            code_col = "股票代码" if "股票代码" in df.columns else "code"
            row = df[df[code_col].astype(str) == code]
            if row.empty:
                logger.debug("Efinance 实时行情未找到股票: code=%s", code)
                return RealtimeQuote()
            row = row.iloc[0]

            price = safe_float(row.get("最新价" if "最新价" in df.columns else "price"))
            change_pct = safe_float(row.get("涨跌幅" if "涨跌幅" in df.columns else "pct_chg"))
            change_amt = safe_float(row.get("涨跌额" if "涨跌额" in df.columns else "change"))
            volume = safe_float(row.get("成交量" if "成交量" in df.columns else "volume"))
            turnover = safe_float(row.get("成交额" if "成交额" in df.columns else "amount"))
            high = safe_float(row.get("最高" if "最高" in df.columns else "high"))
            low = safe_float(row.get("最低" if "最低" in df.columns else "low"))
            open_price = safe_float(row.get("开盘" if "开盘" in df.columns else "open"))
            amplitude = safe_float(row.get("振幅" if "振幅" in df.columns else "amplitude"))
            turnover_rate = safe_float(row.get("换手率" if "换手率" in df.columns else "turnover_rate"))

            prev_close = price - change_amt if price and change_amt else 0.0

            logger.debug("Efinance 实时行情: price=%.2f, change_pct=%.2f%%", price, change_pct)
            logger.debug("Efinance get_realtime_quote 完成 耗时%.2fs", time.monotonic() - start)
            return RealtimeQuote(
                price=price,
                change_pct=change_pct,
                change_amt=change_amt,
                volume=volume,
                turnover=turnover,
                high=high,
                low=low,
                open=open_price,
                prev_close=prev_close,
                amplitude=amplitude,
                turnover_rate=turnover_rate,
            )
        except Exception as e:
            logger.error("EfinanceProvider 获取实时行情失败 %s: %s", code, e)
            return RealtimeQuote()

    async def get_chip_distribution(self, code: str) -> Optional[ChipDistribution]:
        logger.debug("Efinance 不支持筹码分布，返回 None")
        return None

    async def get_market_overview(self) -> MarketOverview:
        indices = await self.get_indices()
        statistics = await self.get_market_statistics()
        top_sectors, bottom_sectors = await self.get_sector_rankings()
        return MarketOverview(
            indices=indices,
            statistics=statistics,
            top_sectors=top_sectors,
            bottom_sectors=bottom_sectors,
        )

    async def get_sector_rankings(self) -> tuple[list[SectorData], list[SectorData]]:
        start = time.monotonic()
        logger.debug("Efinance 获取板块排名...")
        try:
            def _fetch():
                import efinance as ef
                self._enforce_rate_limit()
                return ef.stock.get_realtime_quotes(["行业板块"])

            df = await self._run_sync(_fetch)
            if df is None or df.empty:
                return [], []

            change_col = "涨跌幅" if "涨跌幅" in df.columns else "pct_chg"
            name_col = "股票名称" if "股票名称" in df.columns else "name"
            if change_col not in df.columns or name_col not in df.columns:
                return [], []

            df[change_col] = pd.to_numeric(df[change_col], errors="coerce")
            df = df.dropna(subset=[change_col])

            top = df.nlargest(5, change_col)
            bottom = df.nsmallest(5, change_col)

            top_sectors = [
                SectorData(name=str(r[name_col]), change_pct=float(r[change_col]))
                for _, r in top.iterrows()
            ]
            bottom_sectors = [
                SectorData(name=str(r[name_col]), change_pct=float(r[change_col]))
                for _, r in bottom.iterrows()
            ]
            logger.debug("Efinance 板块排名: 领涨%d个, 领跌%d个", len(top_sectors), len(bottom_sectors))
            logger.debug("Efinance get_sector_rankings 完成 耗时%.2fs", time.monotonic() - start)
            return top_sectors, bottom_sectors
        except Exception as e:
            logger.warning("EfinanceProvider 获取板块涨跌榜失败: %s", e)
            return [], []

    async def get_market_statistics(self) -> MarketStatistics:
        start = time.monotonic()
        logger.debug("Efinance 获取市场统计...")
        try:
            df = await self._run_sync(self._get_realtime_df)
            if df is None or df.empty:
                return MarketStatistics()
            result = calc_market_stats(df, self)
            logger.debug(
                "Efinance 市场统计: 涨%d 跌%d 平%d 涨停%d 跌停%d",
                result.up_count, result.down_count, result.flat_count, result.limit_up_count, result.limit_down_count,
            )
            logger.debug("Efinance get_market_statistics 完成 耗时%.2fs", time.monotonic() - start)
            return result
        except Exception as e:
            logger.warning("EfinanceProvider 获取市场统计失败: %s", e)
            return MarketStatistics()

    async def get_indices(self) -> list[IndexData]:
        start = time.monotonic()
        logger.debug("Efinance 获取指数数据...")
        try:
            def _fetch():
                import efinance as ef
                self._enforce_rate_limit()
                return ef.stock.get_realtime_quotes(["沪深系列指数"])

            df = await self._run_sync(_fetch)
            if df is None or df.empty:
                return []

            code_col = "股票代码" if "股票代码" in df.columns else "code"
            code_series = df[code_col].astype(str).str.zfill(6)

            results: list[IndexData] = []
            for idx_code, idx_name in _INDICES_MAP.items():
                row = df[code_series == idx_code]
                if row.empty:
                    continue
                item = row.iloc[0]
                price_col = "最新价" if "最新价" in df.columns else "price"
                pct_col = "涨跌幅" if "涨跌幅" in df.columns else "pct_chg"
                chg_col = "涨跌额" if "涨跌额" in df.columns else "change"

                close_val = safe_float(item.get(price_col))
                change_pct = safe_float(item.get(pct_col))
                change_amt = safe_float(item.get(chg_col))

                results.append(IndexData(
                    name=idx_name,
                    code=idx_code,
                    close=close_val,
                    change_pct=change_pct,
                    change_amt=change_amt,
                ))
            logger.debug("Efinance 指数数据: %d个", len(results))
            logger.debug("Efinance get_indices 完成 耗时%.2fs", time.monotonic() - start)
            return results
        except Exception as e:
            logger.warning("EfinanceProvider 获取指数数据失败: %s", e)
            return []


path: src/data/manager.py
content: from __future__ import annotations

import asyncio
import logging
import time
from typing import Optional

import pandas as pd

from src.data.provider import MarketDataProvider
from src.data.utils import calculate_chip_from_daily
from src.models import (
    CapitalFlow,
    ChipDistribution,
    FinancialData,
    IndexData,
    MarketOverview,
    MarketStatistics,
    RealtimeQuote,
    SectorData,
    StockInfo,
    Valuation,
)

logger = logging.getLogger(__name__)

_PROVIDER_TIMEOUT = 10.0


class DataProviderManager(MarketDataProvider):

    def __init__(self, providers: list[MarketDataProvider]):
        self.providers = providers
        provider_names = [p.__class__.__name__ for p in providers]
        logger.info("DataProviderManager 初始化: %s", " → ".join(provider_names))

    async def _race_providers(self, method_name: str, *args, **kwargs):
        tasks = []
        for provider in self.providers:
            method = getattr(provider, method_name)
            tasks.append(asyncio.wait_for(method(*args, **kwargs), timeout=_PROVIDER_TIMEOUT))

        results = await asyncio.gather(*tasks, return_exceptions=True)

        for i, result in enumerate(results):
            provider_name = self.providers[i].__class__.__name__
            if isinstance(result, (asyncio.TimeoutError, asyncio.CancelledError)):
                logger.warning("[数据源] %s %s 超时(%.0fs)", provider_name, method_name, _PROVIDER_TIMEOUT)
                continue
            if isinstance(result, Exception):
                logger.warning("[数据源] %s %s 失败: %s", provider_name, method_name, result)
                continue
            logger.info("[数据源] %s %s 成功", provider_name, method_name)
            return result

        return None

    async def get_stock_info(self, code: str) -> StockInfo:
        code = self.normalize_code(code)
        start = time.monotonic()
        result = await self._race_providers("get_stock_info", code)
        if result and isinstance(result, StockInfo) and result.name and result.name != code:
            logger.debug("get_stock_info(%s) 完成 耗时%.2fs", code, time.monotonic() - start)
            return result
        logger.warning("get_stock_info(%s) 所有数据源均失败", code)
        return StockInfo(code=code, name=code)

    async def get_daily_data(self, code: str, days: int = 120) -> pd.DataFrame:
        code = self.normalize_code(code)
        start = time.monotonic()
        tasks = [
            asyncio.wait_for(provider.get_daily_data(code, days), timeout=_PROVIDER_TIMEOUT)
            for provider in self.providers
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        best_df = pd.DataFrame()
        best_len = 0
        for i, result in enumerate(results):
            provider_name = self.providers[i].__class__.__name__
            if isinstance(result, Exception):
                logger.warning("[数据源] %s get_daily_data 失败: %s", provider_name, result)
                continue
            if result is not None and not result.empty:
                result_len = len(result)
                logger.info("[数据源 %d/%d] %s 获取日K线: %d 条 (请求%d天)",
                            i + 1, len(self.providers), provider_name, result_len, days)
                if result_len > best_len:
                    best_df = result
                    best_len = result_len
            else:
                logger.warning("[数据源 %d/%d] %s 日K线数据为空",
                               i + 1, len(self.providers), provider_name)

        if not best_df.empty:
            logger.debug("get_daily_data(%s) 完成 耗时%.2fs (最佳:%d条)",
                         code, time.monotonic() - start, best_len)
        else:
            logger.warning("get_daily_data(%s) 所有数据源均失败", code)
        return best_df

    async def get_realtime_quote(self, code: str) -> RealtimeQuote:
        code = self.normalize_code(code)
        start = time.monotonic()
        result = await self._race_providers("get_realtime_quote", code)
        if result and isinstance(result, RealtimeQuote) and result.price > 0:
            logger.debug("get_realtime_quote(%s) 完成 耗时%.2fs", code, time.monotonic() - start)
            return result
        logger.warning("get_realtime_quote(%s) 所有数据源均失败", code)
        return RealtimeQuote()

    async def get_chip_distribution(self, code: str) -> Optional[ChipDistribution]:
        code = self.normalize_code(code)
        start = time.monotonic()
        result = await self._race_providers("get_chip_distribution", code)
        if result is not None:
            logger.debug("get_chip_distribution(%s) 完成 耗时%.2fs", code, time.monotonic() - start)
            return result

        logger.info("[筹码兜底] 所有数据源筹码接口未返回，尝试K线估算")
        try:
            df = await self.get_daily_data(code, 90)
        except Exception:
            logger.warning("[筹码兜底] K线数据获取失败，筹码分布不可用")
            return None
        result = calculate_chip_from_daily(df)
        if result:
            logger.info("[筹码兜底] K线估算筹码分布成功: 获利比例=%.1f%%, 平均成本=%.2f",
                        result.profit_ratio, result.avg_cost)
        else:
            logger.warning("[筹码兜底] K线估算筹码分布失败: 数据不足")
        logger.debug("get_chip_distribution(%s) 完成 耗时%.2fs", code, time.monotonic() - start)
        return result

    async def get_market_overview(self) -> MarketOverview:
        start = time.monotonic()
        logger.info("开始获取市场概览...")

        indices, statistics, sectors = await asyncio.gather(
            self.get_indices(),
            self.get_market_statistics(),
            self.get_sector_rankings(),
        )
        top_sectors, bottom_sectors = sectors if isinstance(sectors, tuple) else ([], [])

        overview = MarketOverview(
            indices=indices,
            statistics=statistics,
            top_sectors=top_sectors,
            bottom_sectors=bottom_sectors,
        )

        logger.info("市场概览获取完成: 指数%d个, 涨%d/跌%d/平%d, 领涨板块%d个, 领跌板块%d个, 耗时%.2fs",
                    len(indices), statistics.up_count, statistics.down_count,
                    statistics.flat_count, len(top_sectors), len(bottom_sectors),
                    time.monotonic() - start)
        return overview

    async def get_indices(self) -> list[IndexData]:
        result = await self._race_providers("get_indices")
        if result and isinstance(result, list):
            return result
        logger.warning("get_indices() 所有数据源均失败")
        return []

    async def get_sector_rankings(self) -> tuple[list[SectorData], list[SectorData]]:
        result = await self._race_providers("get_sector_rankings")
        if result and isinstance(result, tuple):
            return result
        logger.warning("get_sector_rankings() 所有数据源均失败")
        return [], []

    async def get_market_statistics(self) -> MarketStatistics:
        result = await self._race_providers("get_market_statistics")
        if result and isinstance(result, MarketStatistics) and (result.up_count > 0 or result.down_count > 0):
            return result
        logger.warning("get_market_statistics() 所有数据源均失败")
        return MarketStatistics()

    async def get_capital_flow(self, code: str) -> Optional[CapitalFlow]:
        code = self.normalize_code(code)
        result = await self._race_providers("get_capital_flow", code)
        if result is not None:
            return result
        logger.debug("get_capital_flow(%s) 所有数据源均不支持或失败", code)
        return None

    async def get_valuation(self, code: str) -> Optional[Valuation]:
        code = self.normalize_code(code)
        result = await self._race_providers("get_valuation", code)
        if result is not None:
            return result
        logger.debug("get_valuation(%s) 所有数据源均不支持或失败", code)
        return None

    async def get_financial_data(self, code: str) -> Optional[FinancialData]:
        code = self.normalize_code(code)
        result = await self._race_providers("get_financial_data", code)
        if result is not None:
            return result
        logger.debug("get_financial_data(%s) 所有数据源均不支持或失败", code)
        return None


path: src/data/miaoxiang_provider.py
content: from __future__ import annotations

import asyncio
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple

import pandas as pd
import requests

from src.data.provider import MarketDataProvider
from src.models import (
    CapitalFlow,
    ChipDistribution,
    FinancialData,
    IndexData,
    MarketOverview,
    MarketStatistics,
    RealtimeQuote,
    SectorData,
    StockInfo,
    Valuation,
)

logger = logging.getLogger(__name__)

_MX_API_URL = "https://mkapi2.dfcfs.com/finskillshub/api/claw/query"
_MX_TIMEOUT = 30


def _flatten_value(v: Any) -> str:
    if v is None:
        return ""
    if isinstance(v, (dict, list)):
        import json
        return json.dumps(v, ensure_ascii=False)
    return str(v)


def _format_label(key: str, name_map: Dict[str, Any], code_map: Dict[str, str]) -> str:
    mapped = name_map.get(key) or name_map.get(int(key) if key.isdigit() else key)
    if mapped not in (None, ""):
        return _flatten_value(mapped)
    mapped_code = code_map.get(key)
    if mapped_code not in (None, ""):
        return _flatten_value(mapped_code)
    return ""


def _return_code_map(block: Dict[str, Any]) -> Dict[str, str]:
    for key in ("returnCodeMap", "returnCodeNameMap", "codeMap"):
        data = block.get(key)
        if isinstance(data, dict):
            return {str(k): _flatten_value(v) for k, v in data.items()}
    return {}


def _parse_table_to_rows(block: Dict[str, Any]) -> List[Dict[str, str]]:
    table = block.get("table") or {}
    name_map = block.get("nameMap") or {}
    if isinstance(name_map, list):
        name_map = {str(i): v for i, v in enumerate(name_map)}
    elif not isinstance(name_map, dict):
        name_map = {}

    if not isinstance(table, dict):
        return []

    headers = table.get("headName") or []
    if not isinstance(headers, list):
        return []

    indicator_order = block.get("indicatorOrder") or []
    data_keys = [k for k in table.keys() if k != "headName"]
    key_map = {str(k): k for k in data_keys}

    ordered: List[str] = []
    seen: set = set()
    for key in indicator_order:
        ks = str(key)
        if ks in key_map and ks not in seen:
            ordered.append(key_map[ks])
            seen.add(ks)
    for key in data_keys:
        ks = str(key)
        if ks not in seen:
            ordered.append(key)
            seen.add(ks)

    code_map = _return_code_map(block)
    field_labels: List[Tuple[str, str]] = []
    for key in ordered:
        label = _format_label(str(key), name_map, code_map)
        if label:
            field_labels.append((key, label))

    rows: List[Dict[str, str]] = []
    for row_idx, date_val in enumerate(headers):
        row: Dict[str, str] = {"date": _flatten_value(date_val)}
        for key, label in field_labels:
            raw_values = table.get(key, [])
            value = raw_values[row_idx] if row_idx < len(raw_values) else ""
            row[label] = _flatten_value(value)
        rows.append(row)

    return rows


def _find_value_fuzzy(rows: List[Dict[str, str]], keywords: List[str], row_idx: int = 0) -> str:
    if not rows or row_idx >= len(rows):
        return ""
    row = rows[row_idx]
    for kw in keywords:
        for key, val in row.items():
            if kw in key:
                return val
    return ""


def _find_in_row(row: Dict[str, str], keywords: List[str]) -> str:
    for kw in keywords:
        for key, val in row.items():
            if kw in key:
                return val
    return ""


def _mx_parse_float(raw: str) -> float:
    if not raw or not raw.strip():
        return 0.0
    raw = raw.strip()
    if raw in ("-", "--", "N/A", "null"):
        return 0.0

    has_wan_yi = "万亿" in raw
    has_yi = "亿" in raw and not has_wan_yi
    has_wan = "万" in raw and not has_yi and not has_wan_yi
    has_pct = "%" in raw

    digits = re.sub(r"[^\d.\-]", "", raw)
    if not digits or digits == "-" or digits == ".":
        return 0.0
    try:
        value = float(digits)
    except ValueError:
        return 0.0

    if has_wan_yi:
        return value * 1e12
    if has_yi:
        return value * 1e8
    if has_wan:
        return value * 1e4
    if has_pct:
        return value
    return value


class MiaoxiangProvider(MarketDataProvider):

    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]
        self._session = requests.Session()
        self._session.headers.update({
            "Content-Type": "application/json",
            "apikey": self.api_key,
        })
        logger.info("MiaoxiangProvider 初始化成功")

    def __repr__(self) -> str:
        masked = self.api_key[-4:] if len(self.api_key) >= 4 else "****"
        return f"MiaoxiangProvider([REDACTED_SECRET]')"

    def _query(self, tool_query: str) -> Dict[str, Any]:
        logger.debug("Miaoxiang 查询: %s", tool_query)
        resp = self._session.post(_MX_API_URL, json={"toolQuery": tool_query}, timeout=_MX_TIMEOUT)
        resp.raise_for_status()
        result = resp.json()
        status = result.get("status")
        if status != 0:
            msg = result.get("message", "")
            raise RuntimeError(f"Miaoxiang API error: status={status}, message={msg}")
        return result

    def _query_and_parse(self, tool_query: str) -> List[Dict[str, Any]]:
        result = self._query(tool_query)
        data = result.get("data", {})
        inner = data.get("data", {})
        search = inner.get("searchDataResultDTO", {})
        dto_list = search.get("dataTableDTOList", [])
        return dto_list if isinstance(dto_list, list) else []

    @staticmethod
    def _extract_entity_name(dto: Dict[str, Any]) -> str:
        entity = dto.get("entityTagDTO")
        if isinstance(entity, dict) and entity.get("fullName"):
            return entity["fullName"]
        entity_list = dto.get("entityTagDTOList")
        if isinstance(entity_list, list) and entity_list:
            first = entity_list[0]
            if isinstance(first, dict) and first.get("fullName"):
                return first["fullName"]
        raw = dto.get("entityName") or ""
        return raw.split("(")[0] if raw else ""

    async def get_stock_info(self, code: str) -> StockInfo:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取股票信息: code=%s", code)
        try:
            dto_list = await asyncio.to_thread(
                self._query_and_parse,
                f"{code} 公司简介 行业 总市值 流通市值"
            )
            name = ""
            industry = ""
            market_cap = 0.0

            for dto in dto_list:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue

                if not name:
                    name = self._extract_entity_name(dto)
                    if not name:
                        name = _find_value_fuzzy(rows, ["公司名称", "名称", "简称"])

                if not industry:
                    industry = (
                        _find_value_fuzzy(rows, ["所属中信行业", "所属GICS行业", "所属中证行业"])
                        or _find_value_fuzzy(rows, ["行业"])
                    )
                    if industry and "-" in industry:
                        parts = industry.split("-")
                        industry = parts[-1] if len(parts) <= 3 else "-".join(parts[1:])

                if market_cap <= 0:
                    cap_str = _find_value_fuzzy(rows, ["总市值"])
                    if cap_str:
                        market_cap = _mx_parse_float(cap_str)

            if not name:
                name = code
            logger.debug("Miaoxiang 股票信息: name=%s, industry=%s, market_cap=%.0f", name, industry, market_cap)
            logger.debug("Miaoxiang get_stock_info 完成 耗时%.2fs", time.monotonic() - start)
            return StockInfo(code=code, name=name, industry=industry, market_cap=market_cap)
        except Exception as e:
            logger.warning("Miaoxiang get_stock_info 失败: %s", e)
            raise
        logger.debug("Miaoxiang get_stock_info 完成 耗时%.2fs", time.monotonic() - start)
        return StockInfo(code=code, name=code)

    async def get_daily_data(self, code: str, days: int = 120) -> pd.DataFrame:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取日K线: code=%s, days=%d", code, days)
        try:
            capped = min(days, 55)
            df = await self._fetch_daily_chunk(code, capped)

            if df.empty and days > 55:
                logger.debug("Miaoxiang 55天日K线为空, 尝试30天")
                df = await self._fetch_daily_chunk(code, 30)

            if not df.empty:
                valid_count = int((df["close"] > 0).sum())
                logger.debug("Miaoxiang 日K线处理完成: %d条 (有效%d条, 请求%d天, 上限55天)",
                             len(df), valid_count, days)
            logger.debug("Miaoxiang get_daily_data 完成 耗时%.2fs", time.monotonic() - start)
            return df
        except Exception as e:
            logger.warning("Miaoxiang get_daily_data 失败: %s", e)
            raise
        logger.debug("Miaoxiang get_daily_data 完成 耗时%.2fs", time.monotonic() - start)
        return pd.DataFrame()

    async def _fetch_daily_chunk(self, code: str, days: int) -> pd.DataFrame:
        dto_list = await asyncio.to_thread(
            self._query_and_parse,
            f"{code} 最近{days}个交易日 开盘价 收盘价 最高价 最低价 成交量 成交额"
        )
        for dto in dto_list:
            rows = _parse_table_to_rows(dto)
            if len(rows) < 2:
                continue

            has_close = any(_find_in_row(r, ["收盘价"]) for r in rows)
            if not has_close:
                continue

            is_daily = True
            first_date = rows[0].get("date", "")
            if "(月" in first_date or "月)" in first_date:
                is_daily = False
            if not is_daily:
                logger.debug("Miaoxiang 跳过月度数据 (rows=%d)", len(rows))
                continue

            records = []
            for row in rows:
                date_str = row.get("date", "")
                close_val = _mx_parse_float(_find_in_row(row, ["收盘价"]))
                if close_val <= 0:
                    continue
                records.append({
                    "date": re.sub(r"\(.*?\)", "", date_str).strip(),
                    "open": _mx_parse_float(_find_in_row(row, ["开盘价"])),
                    "close": close_val,
                    "high": _mx_parse_float(_find_in_row(row, ["最高价"])),
                    "low": _mx_parse_float(_find_in_row(row, ["最低价"])),
                    "volume": _mx_parse_float(_find_in_row(row, ["成交量"])),
                    "turnover": _mx_parse_float(_find_in_row(row, ["成交额"])),
                })

            if records:
                df = pd.DataFrame(records)
                if "date" in df.columns:
                    df["date"] = pd.to_datetime(df["date"], errors="coerce")
                    df = df.sort_values("date").reset_index(drop=True)
                return df

        return pd.DataFrame()

    async def get_realtime_quote(self, code: str) -> RealtimeQuote:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取实时行情: code=%s", code)
        try:
            dto_list = await asyncio.to_thread(
                self._query_and_parse,
                f"{code} 最新价 涨跌幅 涨跌额 成交量 成交额 今开 最高 最低 换手率 振幅 昨收"
            )
            price = 0.0
            change_pct = 0.0
            change_amt = 0.0
            volume = 0.0
            turnover = 0.0
            open_price = 0.0
            high = 0.0
            low = 0.0
            prev_close = 0.0
            turnover_rate = 0.0
            amplitude = 0.0

            for dto in dto_list:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue
                row = rows[-1]

                if price <= 0:
                    price = _mx_parse_float(_find_in_row(row, ["最新价"]))
                if change_pct <= 0:
                    change_pct = _mx_parse_float(_find_in_row(row, ["涨跌幅"]))
                if change_amt <= 0:
                    change_amt = _mx_parse_float(_find_in_row(row, ["涨跌额", "涨跌值"]))
                if volume <= 0:
                    volume = _mx_parse_float(_find_in_row(row, ["成交量"]))
                if turnover <= 0:
                    turnover = _mx_parse_float(_find_in_row(row, ["成交额"]))
                if open_price <= 0:
                    open_price = _mx_parse_float(_find_in_row(row, ["今开", "开盘价"]))
                if high <= 0:
                    high = _mx_parse_float(_find_in_row(row, ["最高", "最高价"]))
                if low <= 0:
                    low = _mx_parse_float(_find_in_row(row, ["最低", "最低价"]))
                if prev_close <= 0:
                    prev_close = _mx_parse_float(_find_in_row(row, ["昨收", "昨收价", "昨收盘"]))
                if turnover_rate <= 0:
                    turnover_rate = _mx_parse_float(_find_in_row(row, ["换手率"]))
                if amplitude <= 0:
                    amplitude = _mx_parse_float(_find_in_row(row, ["振幅"]))

            quote = RealtimeQuote(
                price=price,
                change_pct=change_pct,
                change_amt=change_amt,
                volume=volume,
                turnover=turnover,
                open=open_price,
                high=high,
                low=low,
                prev_close=prev_close,
                turnover_rate=turnover_rate,
                amplitude=amplitude,
            )
            logger.debug("Miaoxiang 实时行情: price=%.2f, change_pct=%.2f%%", quote.price, quote.change_pct)
            if quote.price <= 0:
                logger.warning("Miaoxiang 实时行情价格无效: price=%.2f", quote.price)
            logger.debug("Miaoxiang get_realtime_quote 完成 耗时%.2fs", time.monotonic() - start)
            return quote
        except Exception as e:
            logger.warning("Miaoxiang get_realtime_quote 失败: %s", e)
            raise
        logger.debug("Miaoxiang get_realtime_quote 完成 耗时%.2fs", time.monotonic() - start)
        return RealtimeQuote()

    async def get_chip_distribution(self, code: str) -> Optional[ChipDistribution]:
        logger.debug("Miaoxiang 不支持筹码分布，返回 None")
        return None

    async def get_indices(self) -> list[IndexData]:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取指数数据...")
        indices: list[IndexData] = []
        try:
            dto_list = await asyncio.to_thread(
                self._query_and_parse,
                "上证指数 深证成指 创业板指 最新点位 涨跌幅 涨跌额"
            )
            name_to_code = {
                "上证指数": "000001",
                "上证综合指数": "000001",
                "深证成指": "399001",
                "深证成份指数": "399001",
                "创业板指": "399006",
                "创业板指数": "399006",
            }
            code_to_name = {
                "000001": "上证指数",
                "399001": "深证成指",
                "399006": "创业板指",
            }
            seen_codes: set[str] = set()

            realtime_indices: list[IndexData] = []
            historical_indices: list[IndexData] = []
            realtime_dtos = []
            historical_dtos = []

            for dto in dto_list:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue
                first_date = rows[0].get("date", "") if rows else ""
                has_realtime_format = any(kw in first_date for kw in [".SH", ".SZ", ".BJ"])
                if has_realtime_format and "最新价" in (rows[0].keys() if rows else []):
                    realtime_dtos.append(dto)
                else:
                    historical_dtos.append(dto)

            for dto in realtime_dtos:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue
                for row in rows:
                    label = row.get("date", "")
                    code = ""
                    for candidate in ["000001", "399001", "399006"]:
                        if candidate in label:
                            code = candidate
                            break
                    if not code or code in seen_codes:
                        continue
                    close_val = _mx_parse_float(row.get("最新价", ""))
                    if close_val <= 0:
                        continue
                    change_pct = _mx_parse_float(row.get("涨跌幅", ""))
                    change_amt = _mx_parse_float(row.get("涨跌额", row.get("涨跌", "")))
                    if change_amt == 0.0 and close_val > 0 and change_pct != 0.0:
                        change_amt = round(close_val * change_pct / 100, 2)
                    seen_codes.add(code)
                    realtime_indices.append(IndexData(
                        name=code_to_name.get(code, label),
                        code=code,
                        close=close_val,
                        change_pct=change_pct,
                        change_amt=change_amt,
                    ))

            for dto in historical_dtos:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue

                entity = dto.get("entityTagDTO") or {}
                full_name = entity.get("fullName") or ""
                name = full_name or (dto.get("entityName") or "").split("(")[0]
                index_code = name_to_code.get(name, entity.get("secuCode", ""))
                if not index_code or index_code in seen_codes:
                    continue

                row = rows[-1]
                close_val = _mx_parse_float(_find_in_row(row, ["最新价", "最新", "收盘", "点位"]))

                is_daily = "(日)" in row.get("date", "") or "(周" in row.get("date", "") or "(月" in row.get("date", "")
                if is_daily:
                    realtime_val = _mx_parse_float(_find_in_row(row, ["最新价", "最新"]))
                    if realtime_val > 0:
                        close_val = realtime_val

                if close_val <= 0:
                    continue

                change_pct = _mx_parse_float(_find_in_row(row, ["涨跌幅"]))
                change_amt = _mx_parse_float(_find_in_row(row, ["涨跌额", "涨跌值", "涨跌"]))

                seen_codes.add(index_code)
                historical_indices.append(IndexData(
                    name=name,
                    code=index_code,
                    close=close_val,
                    change_pct=change_pct,
                    change_amt=change_amt,
                ))
                logger.debug("Miaoxiang 指数 %s: close=%.2f, change_pct=%.2f%%", name, close_val, change_pct)

            indices = realtime_indices + historical_indices

            logger.debug("Miaoxiang 指数数据: %d个", len(indices))
        except Exception as e:
            logger.warning("Miaoxiang get_indices 失败: %s", e)
            raise
        logger.debug("Miaoxiang get_indices 完成 耗时%.2fs", time.monotonic() - start)
        return indices

    async def get_sector_rankings(self) -> tuple[list[SectorData], list[SectorData]]:
        logger.debug("Miaoxiang 不支持板块排名")
        return [], []

    async def get_market_statistics(self) -> MarketStatistics:
        logger.debug("Miaoxiang 不支持市场统计")
        return MarketStatistics()

    async def get_market_overview(self) -> MarketOverview:
        indices = await self.get_indices()
        statistics = await self.get_market_statistics()
        top_sectors, bottom_sectors = await self.get_sector_rankings()
        return MarketOverview(
            indices=indices,
            statistics=statistics,
            top_sectors=top_sectors,
            bottom_sectors=bottom_sectors,
        )

    async def get_capital_flow(self, code: str) -> Optional[CapitalFlow]:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取主力资金流向: code=%s", code)
        try:
            dto_list = await asyncio.to_thread(
                self._query_and_parse,
                f"{code} 主力资金流向 超大单净额 大单净额 中单净额 小单净额 DDX DDY DDZ"
            )
            super_large_net = 0.0
            large_net = 0.0
            medium_net = 0.0
            small_net = 0.0
            super_large_in = 0.0
            super_large_out = 0.0
            large_in = 0.0
            large_out = 0.0
            ddx = 0.0
            ddy = 0.0
            ddz = 0.0

            for dto in dto_list:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue
                row = rows[-1]

                if super_large_net == 0.0:
                    super_large_net = _mx_parse_float(_find_in_row(row, ["超大单净额", "超大单净流入"]))
                if large_net == 0.0:
                    large_net = _mx_parse_float(_find_in_row(row, ["大单净额", "大单净流入"]))
                if medium_net == 0.0:
                    medium_net = _mx_parse_float(_find_in_row(row, ["中单净额", "中单净流入"]))
                if small_net == 0.0:
                    small_net = _mx_parse_float(_find_in_row(row, ["小单净额", "小单净流入"]))
                if super_large_in == 0.0:
                    super_large_in = _mx_parse_float(_find_in_row(row, ["超大单流入"]))
                if super_large_out == 0.0:
                    super_large_out = _mx_parse_float(_find_in_row(row, ["超大单流出"]))
                if large_in == 0.0:
                    large_in = _mx_parse_float(_find_in_row(row, ["大单流入"]))
                if large_out == 0.0:
                    large_out = _mx_parse_float(_find_in_row(row, ["大单流出"]))
                if ddx == 0.0:
                    ddx = _mx_parse_float(_find_in_row(row, ["DDX"]))
                if ddy == 0.0:
                    ddy = _mx_parse_float(_find_in_row(row, ["DDY"]))
                if ddz == 0.0:
                    ddz = _mx_parse_float(_find_in_row(row, ["DDZ"]))

            has_data = any(v != 0.0 for v in [
                super_large_net, large_net, medium_net, small_net,
                ddx, ddy, ddz,
            ])
            if not has_data:
                logger.debug("Miaoxiang 主力资金: 无有效数据")
                return None

            result = CapitalFlow(
                super_large_net=super_large_net,
                large_net=large_net,
                medium_net=medium_net,
                small_net=small_net,
                super_large_in=super_large_in,
                super_large_out=super_large_out,
                large_in=large_in,
                large_out=large_out,
                ddx=ddx,
                ddy=ddy,
                ddz=ddz,
            )
            logger.debug("Miaoxiang 主力资金: 超大单净=%.0f, 大单净=%.0f, DDX=%.2f",
                         result.super_large_net, result.large_net, result.ddx)
            logger.debug("Miaoxiang get_capital_flow 完成 耗时%.2fs", time.monotonic() - start)
            return result
        except Exception as e:
            logger.warning("Miaoxiang get_capital_flow 失败: %s", e)
            raise

    async def get_valuation(self, code: str) -> Optional[Valuation]:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取估值数据: code=%s", code)
        try:
            dto_list = await asyncio.to_thread(
                self._query_and_parse,
                f"{code} 市盈率 市净率"
            )
            pe_ttm = 0.0
            pb = 0.0
            pe_percentile = 0.0
            pb_percentile = 0.0

            for dto in dto_list:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue
                row = rows[-1]

                if pe_ttm == 0.0:
                    pe_ttm = _mx_parse_float(_find_in_row(row, ["市盈率", "PE(TTM)", "滚动市盈率"]))
                if pb == 0.0:
                    pb = _mx_parse_float(_find_in_row(row, ["市净率", "PB"]))
                if pe_percentile == 0.0:
                    pe_percentile = _mx_parse_float(_find_in_row(row, ["市盈率分位", "PE分位"]))
                if pb_percentile == 0.0:
                    pb_percentile = _mx_parse_float(_find_in_row(row, ["市净率分位", "PB分位"]))

            if pe_ttm <= 0.0 and pb <= 0.0:
                logger.debug("Miaoxiang 估值数据: 无有效数据")
                return None

            result = Valuation(
                pe_ttm=pe_ttm,
                pb=pb,
                pe_percentile=pe_percentile,
                pb_percentile=pb_percentile,
            )
            logger.debug("Miaoxiang 估值: PE=%.1f, PB=%.1f", result.pe_ttm, result.pb)
            logger.debug("Miaoxiang get_valuation 完成 耗时%.2fs", time.monotonic() - start)
            return result
        except Exception as e:
            logger.warning("Miaoxiang get_valuation 失败: %s", e)
            raise

    async def get_financial_data(self, code: str) -> Optional[FinancialData]:
        start = time.monotonic()
        logger.debug("Miaoxiang 获取财务数据: code=%s", code)
        try:
            dto_list = await asyncio.to_thread(
                self._query_and_parse,
                f"{code} 净利润 营业收入 同比增长率 净资产收益率 毛利率 资产负债率 机构持股比例"
            )
            net_profit = 0.0
            revenue = 0.0
            net_profit_yoy = 0.0
            revenue_yoy = 0.0
            roe = 0.0
            gross_margin = 0.0
            debt_ratio = 0.0
            forecast_profit = 0.0
            forecast_growth = 0.0
            institution_holding_pct = 0.0

            for dto in dto_list:
                rows = _parse_table_to_rows(dto)
                if not rows:
                    continue
                row = rows[-1]

                if net_profit == 0.0:
                    net_profit = _mx_parse_float(_find_in_row(row, ["归母净利润", "净利润"]))
                if revenue == 0.0:
                    revenue = _mx_parse_float(_find_in_row(row, ["营业收入", "营收"]))
                if net_profit_yoy == 0.0:
                    net_profit_yoy = _mx_parse_float(_find_in_row(row, ["净利润同比增长", "归母净利润同比增长"]))
                if revenue_yoy == 0.0:
                    revenue_yoy = _mx_parse_float(_find_in_row(row, ["营业收入同比增长", "营收同比增长"]))
                if roe == 0.0:
                    roe = _mx_parse_float(_find_in_row(row, ["净资产收益率", "ROE"]))
                if gross_margin == 0.0:
                    gross_margin = _mx_parse_float(_find_in_row(row, ["销售毛利率", "毛利率"]))
                if debt_ratio == 0.0:
                    debt_ratio = _mx_parse_float(_find_in_row(row, ["资产负债率"]))
                if forecast_profit == 0.0:
                    forecast_profit = _mx_parse_float(_find_in_row(row, ["预测归母净利润中值", "预测净利润"]))
                if forecast_growth == 0.0:
                    forecast_growth = _mx_parse_float(_find_in_row(row, ["预测增长率", "预测净利润增长率"]))
                if institution_holding_pct == 0.0:
                    institution_holding_pct = _mx_parse_float(_find_in_row(row, ["机构持股比例"]))

            has_data = any(v != 0.0 for v in [
                net_profit, revenue, roe, gross_margin, debt_ratio,
            ])
            if not has_data:
                logger.debug("Miaoxiang 财务数据: 无有效数据")
                return None

            result = FinancialData(
                net_profit=net_profit,
                revenue=revenue,
                net_profit_yoy=net_profit_yoy,
                revenue_yoy=revenue_yoy,
                roe=roe,
                gross_margin=gross_margin,
                debt_ratio=debt_ratio,
                forecast_profit=forecast_profit,
                forecast_growth=forecast_growth,
                institution_holding_pct=institution_holding_pct,
            )
            logger.debug("Miaoxiang 财务: 净利润=%.0f, 营收=%.0f, ROE=%.1f%%, 毛利率=%.1f%%",
                         result.net_profit, result.revenue, result.roe, result.gross_margin)
            logger.debug("Miaoxiang get_financial_data 完成 耗时%.2fs", time.monotonic() - start)
            return result
        except Exception as e:
            logger.warning("Miaoxiang get_financial_data 失败: %s", e)
            raise


path: src/data/provider.py
content: from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Optional

import pandas as pd

from src.models import (
    CapitalFlow,
    ChipDistribution,
    FinancialData,
    IndexData,
    MarketOverview,
    MarketStatistics,
    RealtimeQuote,
    StockInfo,
    Valuation,
)


class MarketDataProvider(ABC):
    """行情数据源抽象基类，预留其他数据源接入接口"""

    @abstractmethod
    async def get_stock_info(self, code: str) -> StockInfo:
        """获取股票基本信息"""

    @abstractmethod
    async def get_daily_data(self, code: str, days: int = 120) -> pd.DataFrame:
        """获取日K线数据，返回 DataFrame，列包含 open/high/low/close/volume/change_pct 等"""

    @abstractmethod
    async def get_realtime_quote(self, code: str) -> RealtimeQuote:
        """获取实时行情"""

    @abstractmethod
    async def get_chip_distribution(self, code: str) -> Optional[ChipDistribution]:
        """获取筹码分布，不支持时返回 None"""

    @abstractmethod
    async def get_market_overview(self) -> MarketOverview:
        """获取市场概览（主要指数 + 市场统计 + 板块涨跌）"""

    @abstractmethod
    async def get_sector_rankings(self) -> tuple[list, list]:
        """获取板块涨跌榜，返回 (领涨板块列表, 领跌板块列表)"""

    @abstractmethod
    async def get_market_statistics(self) -> MarketStatistics:
        """获取市场统计（涨跌家数、涨停跌停数）"""

    @abstractmethod
    async def get_indices(self) -> list[IndexData]:
        """获取主要指数数据"""

    async def get_capital_flow(self, code: str) -> Optional[CapitalFlow]:
        """获取主力资金流向，不支持时返回 None"""
        return None

    async def get_valuation(self, code: str) -> Optional[Valuation]:
        """获取估值数据（PE/PB等），不支持时返回 None"""
        return None

    async def get_financial_data(self, code: str) -> Optional[FinancialData]:
        """获取核心财务指标，不支持时返回 None"""
        return None

    def normalize_code(self, code: str) -> str:
        """标准化股票代码，去除可能的前缀后缀"""
        code = code.strip()
        for prefix in ("sh", "sz", "SH", "SZ", "SH.", "SZ."):
            if code.startswith(prefix):
                code = code[len(prefix):]
                break
        if "." in code:
            code = code.split(".")[0]
        return code


path: src/data/utils.py
content: from __future__ import annotations

import random
import time
from typing import Optional

import numpy as np
import pandas as pd

from src.data.provider import MarketDataProvider
from src.models import ChipDistribution, MarketStatistics

USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
]


class RealtimeCache:
    """全市场实时行情缓存（按数据源隔离）"""

    def __init__(self, ttl: int = 600):
        self._data: Optional[pd.DataFrame] = None
        self._timestamp: float = 0
        self._ttl = ttl

    def get(self) -> Optional[pd.DataFrame]:
        if self._data is not None and time.time() - self._timestamp < self._ttl:
            return self._data
        return None

    def set(self, df: pd.DataFrame) -> None:
        self._data = df
        self._timestamp = time.time()


def safe_float(val, default: float = 0.0) -> float:
    try:
        if val is None or val == "" or val == "-":
            return default
        return float(val)
    except (ValueError, TypeError):
        return default


def calc_market_stats(df: pd.DataFrame, provider: MarketDataProvider) -> MarketStatistics:
    """从全市场实时行情 DataFrame 计算涨跌统计（公共实现）"""
    code_col = next((c for c in ["代码", "股票代码", "code"] if c in df.columns), None)
    name_col = next((c for c in ["名称", "股票名称", "name"] if c in df.columns), None)
    close_col = next((c for c in ["最新价", "close"] if c in df.columns), None)
    pre_close_col = next((c for c in ["昨收", "昨日收盘", "pre_close"] if c in df.columns), None)
    amount_col = next((c for c in ["成交额", "amount"] if c in df.columns), None)

    if not all([code_col, name_col, close_col, pre_close_col]):
        return MarketStatistics()

    up_count = down_count = flat_count = limit_up = limit_down = 0

    for code_v, name_v, cur, pre, amt in zip(
        df[code_col], df[name_col], df[close_col],
        df[pre_close_col], df[amount_col] if amount_col else [1] * len(df),
    ):
        if pd.isna(cur) or pd.isna(pre) or cur in ["-"] or pre in ["-"]:
            continue
        if amount_col and (pd.isna(amt) or amt == 0):
            continue

        cur_f = float(cur)
        pre_f = float(pre)
        if pre_f <= 0 or cur_f <= 0:
            continue

        pure_code = provider.normalize_code(str(code_v))
        str_name = str(name_v)

        if pure_code.startswith(("4", "8")) and len(pure_code) == 6:
            ratio = 0.30
        elif pure_code.startswith(("688", "30")):
            ratio = 0.20
        elif "ST" in str_name:
            ratio = 0.05
        else:
            ratio = 0.10

        limit_up_price = np.floor(pre_f * (1 + ratio) * 100 + 0.5) / 100.0
        limit_down_price = np.floor(pre_f * (1 - ratio) * 100 + 0.5) / 100.0
        tol_up = round(abs(pre_f * (1 + ratio) - limit_up_price), 10)
        tol_down = round(abs(pre_f * (1 - ratio) - limit_down_price), 10)

        if abs(cur_f - limit_up_price) <= tol_up:
            limit_up += 1
        if abs(cur_f - limit_down_price) <= tol_down:
            limit_down += 1

        if cur_f > pre_f:
            up_count += 1
        elif cur_f < pre_f:
            down_count += 1
        else:
            flat_count += 1

    return MarketStatistics(
        up_count=up_count,
        down_count=down_count,
        flat_count=flat_count,
        limit_up_count=limit_up,
        limit_down_count=limit_down,
    )


def calculate_chip_from_daily(df: pd.DataFrame) -> Optional[ChipDistribution]:
    """从日K线数据估算筹码分布"""
    if df is None or df.empty or len(df) < 10:
        return None

    close_prices = df["close"].values
    volumes = df["volume"].values
    total_vol = volumes.sum()
    if total_vol == 0:
        return None

    avg_cost = float((close_prices * volumes).sum() / total_vol)
    current_price = float(close_prices[-1])
    profit_ratio = 0.0
    if avg_cost > 0:
        profit_ratio = (current_price - avg_cost) / avg_cost * 100

    sorted_prices = sorted(close_prices)
    n = len(sorted_prices)
    p90 = sorted_prices[int(n * 0.9)]
    p10 = sorted_prices[int(n * 0.1)]
    concentration = 0.0
    if current_price > 0:
        concentration = (p90 - p10) / current_price * 100

    return ChipDistribution(
        profit_ratio=round(profit_ratio, 2),
        avg_cost=round(avg_cost, 2),
        concentration=round(concentration, 2),
        profit_90_cost=round(float(p90), 2),
        profit_10_cost=round(float(p10), 2),
    )


def enforce_rate_limit(sleep_min: float = 1.5, sleep_max: float = 3.0) -> None:
    time.sleep(random.uniform(sleep_min, sleep_max))


path: src/index.py
content: from __future__ import annotations

import dataclasses
import logging
import time
from typing import Optional

from src.analyzer.market import MarketAnalyzer
from src.analyzer.stock import StockAnalyzer
from src.config import SkillConfig, setup_logging
from src.data.akshare_provider import AkShareProvider
from src.data.efinance_provider import EfinanceProvider
from src.data.manager import DataProviderManager
from src.data.miaoxiang_provider import MiaoxiangProvider
from src.llm.client import LLMClient
from src.models import MarketAnalysisResult, StockAnalysisResult
from src.report import save_report
from src.search.base import NewsSearchEngine
from src.search.bocha import BochaSearch
from src.search.brave import BraveSearch
from src.search.miaoxiang import MiaoxiangSearch
from src.search.serpapi import SerpAPISearch
from src.search.tavily import TavilySearch

logger = logging.getLogger(__name__)


def _dataclass_to_dict(obj) -> dict | list | str | int | float | bool | None:
    """递归将 dataclass 转为 JSON 可序列化字典"""
    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
        return {k: _dataclass_to_dict(v) for k, v in dataclasses.asdict(obj).items()}
    if isinstance(obj, list):
        return [_dataclass_to_dict(item) for item in obj]
    if isinstance(obj, dict):
        return {k: _dataclass_to_dict(v) for k, v in obj.items()}
    if isinstance(obj, (str, int, float, bool)) or obj is None:
        return obj
    return str(obj)


def _build_data_provider(config: SkillConfig) -> DataProviderManager:
    providers = []
    if config.mx_apikey:
        providers.append(MiaoxiangProvider(config.mx_apikey))
    providers.extend([EfinanceProvider(), AkShareProvider()])
    return DataProviderManager(providers)


def _build_search_engines(config: SkillConfig) -> list[NewsSearchEngine]:
    engines: list[NewsSearchEngine] = []
    if config.mx_apikey:
        engines.append(MiaoxiangSearch(config.mx_apikey))
    if config.serpapi_key:
        engines.append(SerpAPISearch(config.serpapi_key))
    if config.tavily_key:
        engines.append(TavilySearch(config.tavily_key))
    if config.brave_key:
        engines.append(BraveSearch(config.brave_key))
    if config.bocha_key:
        engines.append(BochaSearch(config.bocha_key))
    return engines


def _build_llm_client(config: SkillConfig) -> LLMClient:
    return LLMClient(
        base_url=config.llm_base_url,
        [REDACTED_SECRET],
        model=config.llm_model,
    )


async def analyze_stock(code: str, config: Optional[SkillConfig] = None,
                        save: bool = False, output_dir: Optional[str] = None) -> StockAnalysisResult:
    """个股分析 - 供智能体调用的主入口

    Args:
        code: A股股票代码
        config: 配置对象，为 None 时从环境变量加载
        save: 是否保存 Markdown 报告到 reports/ 目录
        output_dir: 自定义报告输出目录
    """
    if config is None:
        config = SkillConfig()

    setup_logging()

    errors = config.validate()
    if errors:
        logger.error("配置校验失败: %s", "; ".join(errors))
        return StockAnalysisResult(
            stock_code=code,
            core_conclusion=f"配置错误: {'; '.join(errors)}",
        )

    logger.info("========== 个股分析开始 [%s] ==========", code)
    logger.info("配置: model=%s, base_url=%s, 搜索引擎=%d个, 筹码=%s",
                config.llm_model, config.llm_base_url,
                sum(1 for k in ["serpapi_key", "tavily_key", "brave_key", "bocha_key"]
                    if getattr(config, k, "")),
                "启用" if config.enable_chip else "禁用")

    start = time.monotonic()
    data_provider = _build_data_provider(config)
    llm_client = _build_llm_client(config)
    search_engines = _build_search_engines(config)

    analyzer = StockAnalyzer(
        data_provider=data_provider,
        llm_client=llm_client,
        search_engines=search_engines,
        config=config,
    )

    result = await analyzer.analyze(code)
    elapsed = time.monotonic() - start
    logger.info("========== 个股分析完成 [%s] 耗时%.2fs: %s ==========",
                code, elapsed, result.core_conclusion[:50] if result.core_conclusion else "无结论")
    if save:
        save_report(result, output_dir)
    return result


async def analyze_market(config: Optional[SkillConfig] = None,
                         save: bool = False, output_dir: Optional[str] = None) -> MarketAnalysisResult:
    """市场分析 - 供智能体调用的主入口

    Args:
        config: 配置对象，为 None 时从环境变量加载
        save: 是否保存 Markdown 报告到 reports/ 目录
        output_dir: 自定义报告输出目录
    """
    if config is None:
        config = SkillConfig()

    setup_logging()

    errors = config.validate()
    if errors:
        logger.error("配置校验失败: %s", "; ".join(errors))
        return MarketAnalysisResult(
            core_conclusion=f"配置错误: {'; '.join(errors)}",
        )

    logger.info("========== 市场分析开始 ==========")
    logger.info("配置: model=%s, base_url=%s", config.llm_model, config.llm_base_url)

    start = time.monotonic()
    data_provider = _build_data_provider(config)
    llm_client = _build_llm_client(config)

    analyzer = MarketAnalyzer(
        data_provider=data_provider,
        llm_client=llm_client,
        config=config,
    )

    result = await analyzer.analyze()
    elapsed = time.monotonic() - start
    logger.info("========== 市场分析完成 耗时%.2fs: %s ==========",
                elapsed, result.core_conclusion[:50] if result.core_conclusion else "无结论")
    if save:
        save_report(result, output_dir)
    return result


async def handler(input: dict, context: Optional[dict] = None) -> dict:
    """OpenClaw Skill 标准入口

    Args:
        input: {"mode": "stock"|"market", "code": "600519", ...config overrides}
        context: OpenClaw 运行时上下文

    Returns:
        JSON 可序列化的分析结果字典
    """
    try:
        context = context or {}
        mode = input.get("mode", "stock")
        save = input.get("save", False)
        output_dir = input.get("output_dir")

        config_overrides = {k: v for k, v in input.items() if k not in ("mode", "code", "save", "output_dir")}
        config = SkillConfig(**config_overrides) if config_overrides else None

        if mode == "market":
            result = await analyze_market(config, save=save, output_dir=output_dir)
        else:
            code = input.get("code", "")
            if not code:
                return {"error": "缺少股票代码，请在 input.code 中提供"}
            result = await analyze_stock(code, config, save=save, output_dir=output_dir)

        return _dataclass_to_dict(result)
    except Exception as e:
        logger.error("Skill 执行失败: %s", e, exc_info=True)
        return {"error": f"Skill execution failed: {e}"}


path: src/llm/client.py
content: from __future__ import annotations

import json
import logging
import re

from openai import AsyncOpenAI

logger = logging.getLogger(__name__)


class LLMClient:
    """LLM 客户端，支持 DeepSeek 和 OpenAI 兼容模型"""

    def __init__(self, base_url: str, api_key: str, model: str):
        self.client = AsyncOpenAI(base_url=base_url, [REDACTED_SECRET])
        self.model = model

    async def analyze(self, system_prompt: str, user_prompt: str, temperature: float = 0.3) -> str:
        """调用 LLM 进行分析，返回原始文本"""
        try:
            logger.debug("LLM 请求: model=%s, system_prompt=%d字符, user_prompt=%d字符",
                         self.model, len(system_prompt), len(user_prompt))
            response = await self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_prompt},
                ],
                temperature=temperature,
                max_tokens=8192,
            )
            content = response.choices[0].message.content
            if content is None:
                raise ValueError("LLM 返回内容为空")
            usage = response.usage
            if usage:
                logger.debug("LLM 响应: %d字符, tokens(prompt=%d, completion=%d, total=%d)",
                             len(content), usage.prompt_tokens, usage.completion_tokens, usage.total_tokens)
            else:
                logger.debug("LLM 响应: %d字符", len(content))
            return content.strip()
        except Exception as e:
            logger.error("LLM 调用失败: %s", e)
            raise

    async def analyze_json(self, system_prompt: str, user_prompt: str, temperature: float = 0.3) -> dict:
        """调用 LLM 并解析 JSON 响应"""
        raw = await self.analyze(system_prompt, user_prompt, temperature)
        return self._extract_json(raw)

    @staticmethod
    def _extract_json(text: str) -> dict:
        """从 LLM 输出中提取 JSON，支持 markdown 代码块包裹"""
        json_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
        if json_match:
            text = json_match.group(1).strip()

        try:
            return json.loads(text)
        except json.JSONDecodeError:
            brace_match = re.search(r"\{.*\}", text, re.DOTALL)
            if brace_match:
                try:
                    return json.loads(brace_match.group())
                except json.JSONDecodeError:
                    pass
            logger.warning("无法解析 LLM 输出为 JSON，返回原始文本包装")
            return {"raw_text": text}


path: src/models.py
content: from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional


@dataclass
class StockInfo:
    """股票基本信息"""
    code: str
    name: str
    industry: str = ""
    market_cap: float = 0.0
    list_date: str = ""


@dataclass
class RealtimeQuote:
    """实时行情"""
    price: float = 0.0
    change_pct: float = 0.0
    change_amt: float = 0.0
    volume: float = 0.0
    turnover: float = 0.0
    high: float = 0.0
    low: float = 0.0
    open: float = 0.0
    prev_close: float = 0.0
    amplitude: float = 0.0
    turnover_rate: float = 0.0


@dataclass
class TechnicalIndicators:
    """技术指标"""
    ma5: float = 0.0
    ma10: float = 0.0
    ma20: float = 0.0
    ma60: float = 0.0
    is_bullish_alignment: bool = False
    bias: float = 0.0
    volume_ratio: float = 0.0
    recent_trend: str = ""


@dataclass
class ChipDistribution:
    """筹码分布"""
    profit_ratio: float = 0.0
    avg_cost: float = 0.0
    concentration: float = 0.0
    profit_90_cost: float = 0.0
    profit_10_cost: float = 0.0


@dataclass
class CapitalFlow:
    """主力资金流向"""
    super_large_net: float = 0.0
    large_net: float = 0.0
    medium_net: float = 0.0
    small_net: float = 0.0
    super_large_in: float = 0.0
    super_large_out: float = 0.0
    large_in: float = 0.0
    large_out: float = 0.0
    ddx: float = 0.0
    ddy: float = 0.0
    ddz: float = 0.0


@dataclass
class Valuation:
    """估值数据"""
    pe_ttm: float = 0.0
    pb: float = 0.0
    pe_percentile: float = 0.0
    pb_percentile: float = 0.0


@dataclass
class FinancialData:
    """核心财务指标"""
    net_profit: float = 0.0
    revenue: float = 0.0
    net_profit_yoy: float = 0.0
    revenue_yoy: float = 0.0
    roe: float = 0.0
    gross_margin: float = 0.0
    debt_ratio: float = 0.0
    forecast_profit: float = 0.0
    forecast_growth: float = 0.0
    institution_holding_pct: float = 0.0


@dataclass
class NewsItem:
    """新闻条目"""
    title: str = ""
    snippet: str = ""
    date: str = ""
    source: str = ""
    url: str = ""
    info_type: str = ""
    content: str = ""


@dataclass
class CheckItem:
    """操作检查清单项"""
    condition: str = ""
    status: str = ""
    detail: str = ""


@dataclass
class StockAnalysisResult:
    """个股分析结果"""
    stock_code: str = ""
    stock_name: str = ""
    core_conclusion: str = ""
    score: int = 0
    action: str = ""
    trend: str = ""
    buy_price: Optional[float] = None
    stop_loss_price: Optional[float] = None
    target_price: Optional[float] = None
    checklist: list[CheckItem] = field(default_factory=list)
    risk_alerts: list[str] = field(default_factory=list)
    positive_catalysts: list[str] = field(default_factory=list)
    strategy: str = ""
    raw_report: str = ""
    disclaimer: str = "仅供参考，不构成投资建议。股市有风险，投资需谨慎。"
    stock_info: Optional[StockInfo] = None
    realtime: Optional[RealtimeQuote] = None
    tech: Optional[TechnicalIndicators] = None
    chip: Optional[ChipDistribution] = None
    capital_flow: Optional[CapitalFlow] = None
    valuation: Optional[Valuation] = None
    financial: Optional[FinancialData] = None
    news: list[NewsItem] = field(default_factory=list)


@dataclass
class IndexData:
    """指数数据"""
    name: str = ""
    code: str = ""
    close: float = 0.0
    change_pct: float = 0.0
    change_amt: float = 0.0


@dataclass
class MarketStatistics:
    """市场统计"""
    up_count: int = 0
    down_count: int = 0
    flat_count: int = 0
    limit_up_count: int = 0
    limit_down_count: int = 0


@dataclass
class SectorData:
    """板块数据"""
    name: str = ""
    change_pct: float = 0.0
    lead_stock: str = ""


@dataclass
class MarketOverview:
    """市场概览"""
    indices: list[IndexData] = field(default_factory=list)
    statistics: MarketStatistics = field(default_factory=MarketStatistics)
    top_sectors: list[SectorData] = field(default_factory=list)
    bottom_sectors: list[SectorData] = field(default_factory=list)


@dataclass
class MarketAnalysisResult:
    """市场分析结果"""
    date: str = ""
    core_conclusion: str = ""
    indices: list[IndexData] = field(default_factory=list)
    statistics: MarketStatistics = field(default_factory=MarketStatistics)
    top_sectors: list[SectorData] = field(default_factory=list)
    bottom_sectors: list[SectorData] = field(default_factory=list)
    sentiment: str = ""
    strategy: str = ""
    raw_report: str = ""
    disclaimer: str = "仅供参考，不构成投资建议。股市有风险，投资需谨慎。"


path: src/report.py
content: from __future__ import annotations

import logging
import os
from datetime import datetime
from typing import Union

from src.models import MarketAnalysisResult, StockAnalysisResult

logger = logging.getLogger(__name__)

_REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "reports")


def _ensure_reports_dir() -> str:
    os.makedirs(_REPORTS_DIR, exist_ok=True)
    return _REPORTS_DIR


def _format_price(val) -> str:
    if val is None or val == 0:
        return "—"
    return f"{val:.2f}"


def _format_volume(val) -> str:
    if val is None or val == 0:
        return "—"
    abs_val = abs(val)
    sign = "-" if val < 0 else ""
    if abs_val >= 1e8:
        return f"{sign}{abs_val / 1e8:.2f}亿"
    if abs_val >= 1e4:
        return f"{sign}{abs_val / 1e4:.2f}万"
    return f"{val:.0f}"


def _status_icon(status: str) -> str:
    return {"满足": "✅", "注意": "⚠️", "不满足": "❌"}.get(status, "❓")


def _change_icon(pct: float) -> str:
    if pct > 0:
        return "🟢"
    if pct < 0:
        return "🔴"
    return "⚪"


def _score_bar(score: int, width: int = 20) -> str:
    filled = round(score / 100 * width)
    empty = width - filled
    if score >= 70:
        color = "🟩"
    elif score >= 40:
        color = "🟨"
    else:
        color = "🟥"
    return f"{color * filled}{'⬜' * empty}"


def _action_emoji(action: str) -> str:
    return {"买入": "🟢", "观望": "🟡", "卖出": "🔴"}.get(action, "⚪")


def _sentiment_emoji(sentiment: str) -> str:
    return {"偏多": "🟢", "中性": "🟡", "偏空": "🔴"}.get(sentiment, "⚪")


def generate_stock_report(result: StockAnalysisResult) -> str:
    lines: list[str] = []

    today = datetime.now().strftime("%Y-%m-%d")
    title = f"{result.stock_name or result.stock_code}（{result.stock_code}）分析报告"
    lines.append(f"# {title}")
    lines.append("")

    action_icon = _action_emoji(result.action)
    lines.append(f"> 📅 {today} | {_action_emoji(result.action)} {result.action or '—'} | "
                 f"📊 {_score_bar(result.score)} {result.score}/100 | "
                 f"📈 {result.trend or '—'}")
    lines.append("")

    if result.core_conclusion:
        lines.append("## 💡 核心观点")
        lines.append("")
        lines.append(f"**{result.core_conclusion}**")
        lines.append("")

    rt = result.realtime
    if rt and rt.price > 0:
        lines.append("## 📈 实时行情")
        lines.append("")
        sign = "+" if rt.change_pct > 0 else ""
        change_icon = _change_icon(rt.change_pct)
        lines.append(f"| 当前价 | 涨跌幅 | 涨跌额 | 今开 | 最高 | 最低 | 成交量 | 成交额 | 换手率 | 振幅 |")
        lines.append(f"|--------|--------|--------|------|------|------|--------|--------|--------|------|")
        lines.append(f"| {change_icon} {rt.price:.2f} | {sign}{rt.change_pct:.2f}% | {sign}{rt.change_amt:.2f} "
                     f"| {_format_price(rt.open)} | {_format_price(rt.high)} | {_format_price(rt.low)} "
                     f"| {_format_volume(rt.volume)} | {_format_volume(rt.turnover)} "
                     f"| {rt.turnover_rate:.2f}% | {rt.amplitude:.2f}% |")
        lines.append("")

    valuation = result.valuation
    if valuation and (valuation.pe_ttm > 0 or valuation.pb > 0):
        lines.append("## 📐 估值数据")
        lines.append("")
        lines.append("| 指标 | 数值 | 历史分位 |")
        lines.append("|------|------|----------|")
        if valuation.pe_ttm > 0:
            pe_pct = f"{valuation.pe_percentile:.1f}%" if valuation.pe_percentile > 0 else "—"
            lines.append(f"| 市盈率(TTM) | {valuation.pe_ttm:.1f} | {pe_pct} |")
        if valuation.pb > 0:
            pb_pct = f"{valuation.pb_percentile:.1f}%" if valuation.pb_percentile > 0 else "—"
            lines.append(f"| 市净率 | {valuation.pb:.2f} | {pb_pct} |")
        lines.append("")

    financial = result.financial
    if financial and any(v != 0 for v in [
        financial.net_profit, financial.revenue, financial.roe,
        financial.gross_margin, financial.debt_ratio,
    ]):
        lines.append("## 📋 核心财务指标")
        lines.append("")
        lines.append("| 指标 | 数值 |")
        lines.append("|------|------|")
        if financial.net_profit != 0:
            lines.append(f"| 归母净利润 | {_format_volume(financial.net_profit)} |")
        if financial.revenue != 0:
            lines.append(f"| 营业收入 | {_format_volume(financial.revenue)} |")
        if financial.net_profit_yoy != 0:
            icon = "🟢" if financial.net_profit_yoy > 0 else "🔴"
            lines.append(f"| 净利润同比 | {icon} {financial.net_profit_yoy:.2f}% |")
        if financial.revenue_yoy != 0:
            icon = "🟢" if financial.revenue_yoy > 0 else "🔴"
            lines.append(f"| 营收同比 | {icon} {financial.revenue_yoy:.2f}% |")
        if financial.roe != 0:
            lines.append(f"| ROE | {financial.roe:.2f}% |")
        if financial.gross_margin != 0:
            lines.append(f"| 毛利率 | {financial.gross_margin:.2f}% |")
        if financial.debt_ratio != 0:
            lines.append(f"| 资产负债率 | {financial.debt_ratio:.2f}% |")
        if financial.forecast_profit != 0:
            lines.append(f"| 预测净利润 | {_format_volume(financial.forecast_profit)} |")
        if financial.forecast_growth != 0:
            icon = "🟢" if financial.forecast_growth > 0 else "🔴"
            lines.append(f"| 预测增长率 | {icon} {financial.forecast_growth:.2f}% |")
        if financial.institution_holding_pct != 0:
            lines.append(f"| 机构持股比例 | {financial.institution_holding_pct:.2f}% |")
        lines.append("")

    capital_flow = result.capital_flow
    if capital_flow and any(v != 0 for v in [
        capital_flow.super_large_net, capital_flow.large_net,
        capital_flow.ddx, capital_flow.ddy, capital_flow.ddz,
    ]):
        lines.append("## 💸 主力资金流向")
        lines.append("")
        lines.append("| 资金类型 | 净额 |")
        lines.append("|----------|------|")
        if capital_flow.super_large_net != 0:
            icon = "🟢" if capital_flow.super_large_net > 0 else "🔴"
            lines.append(f"| {icon} 超大单 | {_format_volume(capital_flow.super_large_net)} |")
        if capital_flow.large_net != 0:
            icon = "🟢" if capital_flow.large_net > 0 else "🔴"
            lines.append(f"| {icon} 大单 | {_format_volume(capital_flow.large_net)} |")
        if capital_flow.medium_net != 0:
            icon = "🟢" if capital_flow.medium_net > 0 else "🔴"
            lines.append(f"| {icon} 中单 | {_format_volume(capital_flow.medium_net)} |")
        if capital_flow.small_net != 0:
            icon = "🟢" if capital_flow.small_net > 0 else "🔴"
            lines.append(f"| {icon} 小单 | {_format_volume(capital_flow.small_net)} |")
        lines.append("")
        if any(v != 0 for v in [capital_flow.ddx, capital_flow.ddy, capital_flow.ddz]):
            lines.append("| 技术指标 | 数值 | 含义 |")
            lines.append("|----------|------|------|")
            if capital_flow.ddx != 0:
                icon = "🟢" if capital_flow.ddx > 0 else "🔴"
                lines.append(f"| {icon} DDX | {capital_flow.ddx:.2f} | 大单动向 |")
            if capital_flow.ddy != 0:
                icon = "🟢" if capital_flow.ddy > 0 else "🔴"
                lines.append(f"| {icon} DDY | {capital_flow.ddy:.2f} | 筹码集中度变化 |")
            if capital_flow.ddz != 0:
                icon = "🟢" if capital_flow.ddz > 0 else "🔴"
                lines.append(f"| {icon} DDZ | {capital_flow.ddz:.2f} | 资金强度 |")
            lines.append("")

    tech = result.tech
    if tech and (tech.ma5 > 0 or tech.ma20 > 0):
        lines.append("## 📊 技术指标")
        lines.append("")
        lines.append(f"| MA5 | MA10 | MA20 | MA60 | 多头排列 | 乖离率 | 量比 | 短期趋势 |")
        lines.append(f"|-----|------|------|------|----------|--------|------|----------|")
        bullish = "✅ 是" if tech.is_bullish_alignment else "❌ 否"
        bias_icon = "⚠️" if abs(tech.bias) > 5 else "✅"
        lines.append(f"| {tech.ma5:.2f} | {tech.ma10:.2f} | {tech.ma20:.2f} | {tech.ma60:.2f} "
                     f"| {bullish} | {bias_icon} {tech.bias:.2f}% | {tech.volume_ratio:.2f} "
                     f"| {tech.recent_trend or '—'} |")
        lines.append("")

    chip = result.chip
    if chip and chip.avg_cost > 0:
        lines.append("## 🎯 筹码分布")
        lines.append("")
        profit_icon = "🟢" if chip.profit_ratio > 50 else "🔴"
        lines.append(f"| 获利比例 | 平均成本 | 集中度 | 90%成本 | 10%成本 |")
        lines.append(f"|----------|----------|--------|---------|---------|")
        lines.append(f"| {profit_icon} {chip.profit_ratio:.1f}% | {chip.avg_cost:.2f} "
                     f"| {chip.concentration:.2f}% | {chip.profit_90_cost:.2f} | {chip.profit_10_cost:.2f} |")
        lines.append("")
        if chip.avg_cost > 0 and rt and rt.price > 0:
            ratio = min(1.0, rt.price / (chip.avg_cost * 2))
            lines.append(f"**价格位置**：当前 {rt.price:.2f} 相对平均成本 {chip.avg_cost:.2f}")
            lines.append(f"```")
            lines.append(f"  10%成本 ←{'─' * 6} 平均成本 ←{'─' * 6} 90%成本")
            lines.append(f"  {chip.profit_10_cost:.2f}      {chip.avg_cost:.2f}      {chip.profit_90_cost:.2f}")
            lines.append(f"       {'▔' * 10} 当前价 {rt.price:.2f}")
            lines.append(f"```")
            lines.append("")

    if result.buy_price is not None or result.stop_loss_price is not None or result.target_price is not None:
        lines.append("## 💰 关键价位")
        lines.append("")
        lines.append(f"| 方向 | 买入价 | 止损价 | 目标价 | 盈亏比 |")
        lines.append(f"|------|--------|--------|--------|--------|")
        risk_reward = "—"
        if result.buy_price and result.stop_loss_price and result.target_price:
            risk = result.buy_price - result.stop_loss_price
            reward = result.target_price - result.buy_price
            if risk > 0:
                risk_reward = f"{reward / risk:.1f}:1"
        lines.append(f"| {_action_emoji(result.action)} {result.action or '—'} "
                     f"| {_format_price(result.buy_price)} | {_format_price(result.stop_loss_price)} "
                     f"| {_format_price(result.target_price)} | {risk_reward} |")
        lines.append("")

    if result.checklist:
        passed = sum(1 for item in result.checklist if item.status == "满足")
        total = len(result.checklist)
        lines.append(f"## ✅ 操作检查清单（{passed}/{total} 通过）")
        lines.append("")
        lines.append("| 条件 | 状态 | 详情 |")
        lines.append("|------|------|------|")
        for item in result.checklist:
            icon = _status_icon(item.status)
            lines.append(f"| {item.condition} | {icon} {item.status} | {item.detail} |")
        lines.append("")

    if result.risk_alerts or result.positive_catalysts:
        lines.append("## ⚡ 风险与催化")
        lines.append("")
        if result.risk_alerts:
            lines.append("**风险警报**：")
            for alert in result.risk_alerts:
                lines.append(f"- 🔴 {alert}")
            lines.append("")
        if result.positive_catalysts:
            lines.append("**利好催化**：")
            for catalyst in result.positive_catalysts:
                lines.append(f"- 🟢 {catalyst}")
            lines.append("")

    if result.news:
        total = len(result.news)
        lines.append(f"## 📰 近期资讯（{total}条）")
        lines.append("")
        for item in result.news:
            date_str = f"[{item.date}] " if item.date else ""
            type_tag = {"report": "[研报]", "announcement": "[公告]"}.get(item.info_type, "")
            source_str = f" — {item.source}" if item.source else ""
            lines.append(f"- {date_str}**{type_tag}{item.title}**{source_str}")
            text = item.snippet or item.content
            if text:
                lines.append(f"  > {text[:300]}")
        lines.append("")

    if result.raw_report:
        lines.append("## 📝 深度分析")
        lines.append("")
        lines.append(result.raw_report)
        lines.append("")

    if result.strategy:
        lines.append("## 🎯 操作建议")
        lines.append("")
        lines.append(result.strategy)
        lines.append("")

    lines.append("---")
    lines.append("")
    lines.append(f"⚠️ {result.disclaimer}")

    return "\n".join(lines)


def generate_market_report(result: MarketAnalysisResult) -> str:
    lines: list[str] = []

    date_str = result.date or datetime.now().strftime("%Y-%m-%d")
    title = f"A股市场复盘报告 — {date_str}"
    lines.append(f"# {title}")
    lines.append("")

    sentiment_icon = _sentiment_emoji(result.sentiment)
    lines.append(f"> 📅 {date_str} | {sentiment_icon} 市场情绪：{result.sentiment or '—'}")
    lines.append("")

    if result.core_conclusion:
        lines.append("## 💡 核心观点")
        lines.append("")
        lines.append(f"**{result.core_conclusion}**")
        lines.append("")

    if result.indices:
        lines.append("## 📈 主要指数")
        lines.append("")
        lines.append("| 指数 | 收盘 | 涨跌幅 | 涨跌额 |")
        lines.append("|------|------|--------|--------|")
        for idx in result.indices:
            icon = _change_icon(idx.change_pct)
            sign = "+" if idx.change_pct > 0 else ""
            lines.append(f"| {icon} {idx.name} | {idx.close:.2f} | {sign}{idx.change_pct:.2f}% | {sign}{idx.change_amt:.2f} |")
        lines.append("")

    stat = result.statistics
    if stat.up_count > 0 or stat.down_count > 0:
        total = stat.up_count + stat.down_count + stat.flat_count
        up_ratio = stat.up_count / total if total > 0 else 0
        down_ratio = stat.down_count / total if total > 0 else 0
        flat_ratio = stat.flat_count / total if total > 0 else 0

        lines.append("## 📊 市场统计")
        lines.append("")
        lines.append(f"| 上涨 🟢 | 下跌 🔴 | 平盘 ⚪ | 涨停 🔥 | 跌停 ❄️ |")
        lines.append(f"|---------|---------|---------|---------|---------|")
        lines.append(f"| {stat.up_count} | {stat.down_count} | {stat.flat_count} "
                     f"| {stat.limit_up_count} | {stat.limit_down_count} |")
        lines.append("")
        lines.append("**涨跌比例**：")
        bar_width = 30
        up_fill = round(up_ratio * bar_width)
        down_fill = round(down_ratio * bar_width)
        flat_fill = bar_width - up_fill - down_fill
        lines.append(f"```")
        lines.append(f"  🟢 上涨 {up_ratio:.1%}  {'█' * up_fill}")
        lines.append(f"  ⚪ 平盘 {flat_ratio:.1%}  {'█' * flat_fill}")
        lines.append(f"  🔴 下跌 {down_ratio:.1%}  {'█' * down_fill}")
        lines.append(f"```")
        lines.append("")

    if result.top_sectors or result.bottom_sectors:
        lines.append("## 🔥 板块排名")
        lines.append("")

        if result.top_sectors:
            max_pct = max(abs(s.change_pct) for s in result.top_sectors) if result.top_sectors else 1
            lines.append("**领涨板块**：")
            lines.append("")
            for s in result.top_sectors:
                bar_len = max(1, round(s.change_pct / max_pct * 15)) if max_pct > 0 else 0
                lines.append(f"  🔥 {s.name:<8} {'█' * bar_len} +{s.change_pct:.2f}%  龙头：{s.lead_stock or '—'}")
            lines.append("")

        if result.bottom_sectors:
            max_pct = max(abs(s.change_pct) for s in result.bottom_sectors) if result.bottom_sectors else 1
            lines.append("**领跌板块**：")
            lines.append("")
            for s in result.bottom_sectors:
                bar_len = max(1, round(abs(s.change_pct) / max_pct * 15)) if max_pct > 0 else 0
                lines.append(f"  ❄️ {s.name:<8} {'░' * bar_len} {s.change_pct:.2f}%  龙头：{s.lead_stock or '—'}")
            lines.append("")

    if result.strategy:
        lines.append("## 🎯 操作建议")
        lines.append("")
        lines.append(result.strategy)
        lines.append("")

    if result.raw_report:
        lines.append("## 📝 详细复盘")
        lines.append("")
        lines.append(result.raw_report)
        lines.append("")

    lines.append("---")
    lines.append("")
    lines.append(f"⚠️ {result.disclaimer}")

    return "\n".join(lines)


def save_report(result: Union[StockAnalysisResult, MarketAnalysisResult],
                output_dir: Union[str, None] = None) -> str:
    if output_dir is None:
        output_dir = _ensure_reports_dir()
    else:
        os.makedirs(output_dir, exist_ok=True)

    today = datetime.now().strftime("%Y-%m-%d")

    if isinstance(result, StockAnalysisResult):
        code = result.stock_code or "unknown"
        filename = f"{code}_{today}.md"
        content = generate_stock_report(result)
    elif isinstance(result, MarketAnalysisResult):
        filename = f"market_{today}.md"
        content = generate_market_report(result)
    else:
        raise ValueError(f"不支持的结果类型: {type(result)}")

    filepath = os.path.join(output_dir, filename)
    with open(filepath, "w", encoding="utf-8") as f:
        f.write(content)

    logger.info("报告已保存: %s", filepath)
    return filepath


path: src/search/base.py
content: from __future__ import annotations

from abc import ABC, abstractmethod

from src.models import NewsItem


class NewsSearchEngine(ABC):
    """新闻搜索引擎抽象基类"""

    @abstractmethod
    async def search(self, query: str, max_age_days: int = 3) -> list[NewsItem]:
        """搜索新闻，返回新闻条目列表"""

    @property
    @abstractmethod
    def name(self) -> str:
        """搜索引擎名称"""


path: src/search/bocha.py
content: from __future__ import annotations

import logging

import httpx

from src.models import NewsItem
from src.search.base import NewsSearchEngine

logger = logging.getLogger(__name__)


class BochaSearch(NewsSearchEngine):
    """Bocha（博查）搜索引擎"""

    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]
        self.base_url = "https://api.bochaai.com/v1/web-search"

    @property
    def name(self) -> str:
        return "Bocha"

    async def search(self, query: str, max_age_days: int = 3) -> list[NewsItem]:
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }
        payload = {
            "query": query,
            "freshness": f"oneDay" if max_age_days <= 1 else "oneWeek" if max_age_days <= 7 else "oneMonth",
            "summary": False,
            "count": 10,
        }
        try:
            async with httpx.AsyncClient(timeout=15) as client:
                resp = await client.post(self.base_url, headers=headers, json=payload)
                resp.raise_for_status()
                data = resp.json()

            results = []
            for item in data.get("data", {}).get("webPages", {}).get("value", [])[:10]:
                results.append(NewsItem(
                    title=item.get("name", ""),
                    snippet=item.get("snippet", ""),
                    date=item.get("dateLastCrawled", ""),
                    source=item.get("siteName", ""),
                    url=item.get("url", ""),
                ))
            return results
        except Exception as e:
            logger.warning("Bocha 搜索失败: %s", e)
            return []


path: src/search/brave.py
content: from __future__ import annotations

import logging

import httpx

from src.models import NewsItem
from src.search.base import NewsSearchEngine

logger = logging.getLogger(__name__)


class BraveSearch(NewsSearchEngine):
    """Brave 搜索引擎"""

    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]
        self.base_url = "https://api.search.brave.com/res/v1/news/search"

    @property
    def name(self) -> str:
        return "Brave"

    async def search(self, query: str, max_age_days: int = 3) -> list[NewsItem]:
        headers = {
            "Accept": "application/json",
            "Accept-Encoding": "gzip",
            "X-Subscription-Token": self.api_key,
        }
        params = {
            "q": query,
            "count": 10,
            "freshness": f"pd{max_age_days}" if max_age_days <= 7 else "pw",
        }
        try:
            async with httpx.AsyncClient(timeout=15) as client:
                resp = await client.get(self.base_url, headers=headers, params=params)
                resp.raise_for_status()
                data = resp.json()

            results = []
            for item in data.get("results", [])[:10]:
                item_data = item if isinstance(item, dict) else {}
                results.append(NewsItem(
                    title=item_data.get("title", ""),
                    snippet=item_data.get("description", ""),
                    date=item_data.get("age", ""),
                    source=item_data.get("source", ""),
                    url=item_data.get("url", ""),
                ))
            return results
        except Exception as e:
            logger.warning("Brave 搜索失败: %s", e)
            return []


path: src/search/miaoxiang.py
content: from __future__ import annotations

import asyncio
import logging
import re
from datetime import datetime, timedelta
from typing import Any, Dict, List

import requests

from src.models import NewsItem
from src.search.base import NewsSearchEngine

logger = logging.getLogger(__name__)

_MX_NEWS_URL = "https://mkapi2.dfcfs.com/finskillshub/api/claw/news-search"
_MX_TIMEOUT = 30

_TYPE_MAP = {
    "INV_NEWS": "news",
    "REPORT": "report",
    "NOTICE": "announcement",
}

_TYPE_LABEL_MAP = {
    "INV_NEWS": "新闻",
    "REPORT": "研报",
    "NOTICE": "公告",
}

_TYPE_PRIORITY = {"news": 0, "report": 1, "announcement": 2}


def _parse_date(date_str: str) -> datetime:
    if not date_str:
        return datetime.min
    cleaned = re.sub(r"[年月]", "-", date_str).replace("日", " ").strip()
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
        try:
            return datetime.strptime(cleaned[:len(fmt)], fmt)
        except ValueError:
            continue
    for fmt in ("%Y-%m-%d",):
        try:
            return datetime.strptime(cleaned.split()[0], fmt)
        except ValueError:
            continue
    return datetime.min


def _is_within_days(dt: datetime, max_age_days: int) -> bool:
    if dt == datetime.min:
        return True
    return dt >= datetime.now() - timedelta(days=max_age_days)


class MiaoxiangSearch(NewsSearchEngine):

    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]
        self._session = requests.Session()
        self._session.headers.update({
            "Content-Type": "application/json",
            "apikey": self.api_key,
        })
        logger.debug("MiaoxiangSearch 初始化成功")

    @property
    def name(self) -> str:
        return "MiaoxiangSearch"

    async def search(self, query: str, max_age_days: int = 3) -> list[NewsItem]:
        logger.debug("MiaoxiangSearch 查询: %s", query)
        try:
            result = await asyncio.to_thread(self._do_search, query)
            items = self._parse_results(result, max_age_days)
            logger.info("MiaoxiangSearch 返回 %d 条资讯 (query=%s)", len(items), query[:30])
            return items
        except Exception as e:
            logger.warning("MiaoxiangSearch 搜索失败: %s", e)
            raise

    def _do_search(self, query: str) -> Dict[str, Any]:
        resp = self._session.post(
            _MX_NEWS_URL,
            json={"query": query},
            timeout=_MX_TIMEOUT,
        )
        resp.raise_for_status()
        return resp.json()

    def _parse_results(self, result: Dict[str, Any], max_age_days: int) -> list[NewsItem]:
        status = result.get("status")
        if status != 0:
            msg = result.get("message", "")
            logger.warning("MiaoxiangSearch API error: status=%s, message=%s", status, msg)
            return []

        data = result.get("data", {})
        inner = data.get("data", {})
        search_response = inner.get("llmSearchResponse", {})
        raw_items = search_response.get("data", [])

        if not raw_items:
            logger.debug("MiaoxiangSearch: 无搜索结果")
            return []

        items: list[NewsItem] = []
        for raw in raw_items:
            info_type_raw = raw.get("informationType", "INV_NEWS")
            info_type = _TYPE_MAP.get(info_type_raw, "news")
            dt = _parse_date(raw.get("date", ""))

            if not _is_within_days(dt, max_age_days):
                continue

            title = raw.get("title", "")
            content = raw.get("content", "")
            snippet = content[:300] if content else ""

            ins_name = raw.get("insName", "")
            rating = raw.get("rating", "")
            type_label = _TYPE_LABEL_MAP.get(info_type_raw, "资讯")

            source_parts = []
            if ins_name:
                source_parts.append(ins_name)
            source_parts.append(type_label)
            if rating:
                source_parts.append(rating)
            source = "·".join(source_parts)

            date_str = raw.get("date", "")
            if isinstance(date_str, str) and len(date_str) > 10:
                date_str = date_str[:10]

            items.append(NewsItem(
                title=title,
                snippet=snippet,
                date=date_str,
                source=source,
                url="",
                info_type=info_type,
                content=content,
            ))

        items.sort(key=lambda x: _TYPE_PRIORITY.get(x.info_type, 9))
        return items


path: src/search/serpapi.py
content: from __future__ import annotations

import logging

import httpx

from src.models import NewsItem
from src.search.base import NewsSearchEngine

logger = logging.getLogger(__name__)


class SerpAPISearch(NewsSearchEngine):
    """SerpAPI 搜索引擎"""

    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]
        self.base_url = "https://serpapi.com/search"

    @property
    def name(self) -> str:
        return "SerpAPI"

    async def search(self, query: str, max_age_days: int = 3) -> list[NewsItem]:
        params = {
            "q": query,
            "api_key": self.api_key,
            "engine": "google",
            "tbm": "nws",
            "num": 10,
            "hl": "zh-cn",
            "gl": "cn",
        }
        try:
            async with httpx.AsyncClient(timeout=15) as client:
                resp = await client.get(self.base_url, params=params)
                resp.raise_for_status()
                data = resp.json()

            results = []
            for item in data.get("news_results", [])[:10]:
                date_str = item.get("date", "")
                results.append(NewsItem(
                    title=item.get("title", ""),
                    snippet=item.get("snippet", ""),
                    date=date_str,
                    source=item.get("source", ""),
                    url=item.get("link", ""),
                ))
            return results
        except Exception as e:
            logger.warning("SerpAPI 搜索失败: %s", e)
            return []


path: src/search/tavily.py
content: from __future__ import annotations

import logging

import httpx

from src.models import NewsItem
from src.search.base import NewsSearchEngine

logger = logging.getLogger(__name__)


class TavilySearch(NewsSearchEngine):
    """Tavily 搜索引擎"""

    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]
        self.base_url = "https://api.tavily.com/search"

    @property
    def name(self) -> str:
        return "Tavily"

    async def search(self, query: str, max_age_days: int = 3) -> list[NewsItem]:
        payload = {
            "api_key": self.api_key,
            "query": query,
            "search_depth": "basic",
            "include_answer": False,
            "max_results": 10,
            "topic": "news",
        }
        try:
            async with httpx.AsyncClient(timeout=15) as client:
                resp = await client.post(self.base_url, json=payload)
                resp.raise_for_status()
                data = resp.json()

            results = []
            for item in data.get("results", [])[:10]:
                results.append(NewsItem(
                    title=item.get("title", ""),
                    snippet=item.get("content", ""),
                    date=item.get("published_date", ""),
                    source=item.get("url", ""),
                    url=item.get("url", ""),
                ))
            return results
        except Exception as e:
            logger.warning("Tavily 搜索失败: %s", e)
            return []


path: test/test_config.py
content: from __future__ import annotations

import pytest

from src.config import SkillConfig


class TestSkillConfig:
    def test_defaults_from_env(self, monkeypatch):
        monkeypatch.setenv("LLM_API_KEY", "test-key")
        monkeypatch.setenv("LLM_BASE_URL", "https://api.example.com/v1")
        monkeypatch.setenv("LLM_MODEL", "test-model")

        config = SkillConfig()
        assert config.llm_api_key == "test-key"
        assert config.llm_base_url == "https://api.example.com/v1"
        assert config.llm_model == "test-model"

    def test_validate_success(self, monkeypatch):
        monkeypatch.setenv("LLM_API_KEY", "test-key")
        monkeypatch.setenv("LLM_BASE_URL", "https://api.example.com/v1")
        monkeypatch.setenv("LLM_MODEL", "test-model")

        config = SkillConfig()
        errors = config.validate()
        assert errors == []

    def test_validate_missing_api_key(self, monkeypatch):
        monkeypatch.delenv("LLM_API_KEY", raising=False)
        monkeypatch.setenv("LLM_BASE_URL", "https://api.example.com/v1")
        monkeypatch.setenv("LLM_MODEL", "test-model")

        config = SkillConfig(llm_api_key="")
        errors = config.validate()
        assert len(errors) == 1
        assert "LLM_API_KEY" in errors[0]

    def test_validate_missing_base_url(self, monkeypatch):
        monkeypatch.setenv("LLM_API_KEY", "test-key")
        monkeypatch.delenv("LLM_BASE_URL", raising=False)
        monkeypatch.setenv("LLM_MODEL", "test-model")

        config = SkillConfig(llm_base_url="")
        errors = config.validate()
        assert len(errors) == 1
        assert "LLM_BASE_URL" in errors[0]

    def test_validate_missing_model(self, monkeypatch):
        monkeypatch.setenv("LLM_API_KEY", "test-key")
        monkeypatch.setenv("LLM_BASE_URL", "https://api.example.com/v1")
        monkeypatch.delenv("LLM_MODEL", raising=False)

        config = SkillConfig(llm_model="")
        errors = config.validate()
        assert len(errors) == 1
        assert "LLM_MODEL" in errors[0]

    def test_has_search_engine_true(self, monkeypatch):
        monkeypatch.setenv("SERPAPI_KEY", "some-key")

        config = SkillConfig(serpapi_key="some-key")
        assert config.has_search_engine is True

    def test_has_search_engine_false(self, monkeypatch):
        monkeypatch.delenv("SERPAPI_KEY", raising=False)
        monkeypatch.delenv("TAVILY_KEY", raising=False)
        monkeypatch.delenv("BRAVE_KEY", raising=False)
        monkeypatch.delenv("BOCHA_KEY", raising=False)

        config = SkillConfig(serpapi_key="", tavily_key="", brave_key="", bocha_key="")
        assert config.has_search_engine is False

    def test_optional_fields_defaults(self, monkeypatch):
        monkeypatch.delenv("MX_APIKEY", raising=False)
        monkeypatch.delenv("BIAS_THRESHOLD", raising=False)
        monkeypatch.delenv("NEWS_MAX_AGE_DAYS", raising=False)
        monkeypatch.delenv("ENABLE_CHIP", raising=False)

        config = SkillConfig()
        assert config.mx_apikey == ""
        assert config.bias_threshold == 5.0
        assert config.news_max_age_days == 3
        assert config.enable_chip is True


path: test/test_index.py
content: from __future__ import annotations

import asyncio

import pytest

from src.index import _dataclass_to_dict, handler
from src.models import CheckItem, StockAnalysisResult


class TestDataclassToDict:
    def test_primitive_types(self):
        assert _dataclass_to_dict(42) == 42
        assert _dataclass_to_dict("hello") == "hello"
        assert _dataclass_to_dict(3.14) == 3.14
        assert _dataclass_to_dict(True) is True
        assert _dataclass_to_dict(None) is None

    def test_list(self):
        assert _dataclass_to_dict([1, 2, 3]) == [1, 2, 3]

    def test_dict(self):
        assert _dataclass_to_dict({"a": 1}) == {"a": 1}

    def test_dataclass(self):
        result = StockAnalysisResult(stock_code="600519", score=75)
        d = _dataclass_to_dict(result)
        assert d["stock_code"] == "600519"
        assert d["score"] == 75
        assert isinstance(d["checklist"], list)

    def test_nested_dataclass(self):
        result = StockAnalysisResult(
            stock_code="600519",
            checklist=[CheckItem(condition="test", status="✅", detail="ok")],
        )
        d = _dataclass_to_dict(result)
        assert len(d["checklist"]) == 1
        assert d["checklist"][0]["condition"] == "test"


class TestHandler:
    def test_missing_code_returns_error(self):
        result = asyncio.run(handler({"mode": "stock"}))
        assert "error" in result
        assert "股票代码" in result["error"]

    def test_missing_llm_config_returns_config_error(self, monkeypatch):
        monkeypatch.delenv("LLM_API_KEY", raising=False)
        monkeypatch.delenv("LLM_BASE_URL", raising=False)
        monkeypatch.delenv("LLM_MODEL", raising=False)

        result = asyncio.run(handler({"mode": "stock", "code": "600519"}))
        if "error" in result:
            assert "execution" in result["error"].lower() or "配置" in result.get("core_conclusion", "")
        else:
            assert result.get("stock_code") == "600519"


path: test/test_manager.py
content: from __future__ import annotations

import asyncio
from typing import Optional
from unittest.mock import AsyncMock

import pandas as pd
import pytest

from src.data.manager import DataProviderManager
from src.data.provider import MarketDataProvider
from src.models import (
    CapitalFlow,
    ChipDistribution,
    FinancialData,
    IndexData,
    MarketOverview,
    MarketStatistics,
    RealtimeQuote,
    SectorData,
    StockInfo,
    Valuation,
)


class _StubProvider(MarketDataProvider):
    """Minimal stub that raises NotImplementedError for every abstract method"""

    async def get_stock_info(self, code: str) -> StockInfo:
        raise NotImplementedError

    async def get_daily_data(self, code: str, days: int = 120) -> pd.DataFrame:
        raise NotImplementedError

    async def get_realtime_quote(self, code: str) -> RealtimeQuote:
        raise NotImplementedError

    async def get_chip_distribution(self, code: str) -> Optional[ChipDistribution]:
        raise NotImplementedError

    async def get_market_overview(self) -> MarketOverview:
        raise NotImplementedError

    async def get_sector_rankings(self) -> tuple[list[SectorData], list[SectorData]]:
        raise NotImplementedError

    async def get_market_statistics(self) -> MarketStatistics:
        raise NotImplementedError

    async def get_indices(self) -> list[IndexData]:
        raise NotImplementedError


def _make_provider(**overrides) -> AsyncMock:
    provider = AsyncMock(spec=_StubProvider)
    for method_name in (
        "get_stock_info", "get_daily_data", "get_realtime_quote",
        "get_chip_distribution", "get_market_overview",
        "get_sector_rankings", "get_market_statistics", "get_indices",
        "get_capital_flow", "get_valuation", "get_financial_data",
        "normalize_code",
    ):
        if method_name not in overrides:
            setattr(provider, method_name, AsyncMock(side_effect=NotImplementedError))
    for name, value in overrides.items():
        setattr(provider, name, value)
    provider.normalize_code = lambda code: code
    return provider


class TestRaceProvidersSignatureFix:
    """Verify _race_providers correctly handles methods with and without code parameter"""

    def test_get_indices_no_code_parameter(self):
        provider = _make_provider(
            get_indices=AsyncMock(return_value=[
                IndexData(name="上证指数", code="000001", close=3200.0, change_pct=0.5),
            ]),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_indices())
        assert len(result) == 1
        assert result[0].name == "上证指数"
        assert result[0].close == 3200.0
        provider.get_indices.assert_awaited_once_with()

    def test_get_sector_rankings_no_code_parameter(self):
        top = [SectorData(name="半导体", change_pct=3.2)]
        bottom = [SectorData(name="房地产", change_pct=-1.5)]
        provider = _make_provider(
            get_sector_rankings=AsyncMock(return_value=(top, bottom)),
        )
        manager = DataProviderManager([provider])

        result_top, result_bottom = asyncio.run(manager.get_sector_rankings())
        assert len(result_top) == 1
        assert result_top[0].name == "半导体"
        assert len(result_bottom) == 1
        assert result_bottom[0].name == "房地产"
        provider.get_sector_rankings.assert_awaited_once_with()

    def test_get_market_statistics_no_code_parameter(self):
        provider = _make_provider(
            get_market_statistics=AsyncMock(return_value=MarketStatistics(
                up_count=3000, down_count=1500, flat_count=200,
                limit_up_count=50, limit_down_count=10,
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_market_statistics())
        assert result.up_count == 3000
        assert result.down_count == 1500
        assert result.limit_up_count == 50
        provider.get_market_statistics.assert_awaited_once_with()

    def test_get_stock_info_with_code_parameter(self):
        provider = _make_provider(
            get_stock_info=AsyncMock(return_value=StockInfo(
                code="600519", name="贵州茅台", industry="白酒",
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_stock_info("600519"))
        assert result.name == "贵州茅台"
        assert result.code == "600519"
        provider.get_stock_info.assert_awaited_once_with("600519")

    def test_get_realtime_quote_with_code_parameter(self):
        provider = _make_provider(
            get_realtime_quote=AsyncMock(return_value=RealtimeQuote(
                price=1800.0, change_pct=1.5,
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_realtime_quote("600519"))
        assert result.price == 1800.0
        assert result.change_pct == 1.5
        provider.get_realtime_quote.assert_awaited_once_with("600519")

    def test_get_capital_flow_with_code_parameter(self):
        provider = _make_provider(
            get_capital_flow=AsyncMock(return_value=CapitalFlow(
                super_large_net=5e8, ddx=0.26,
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_capital_flow("600519"))
        assert result is not None
        assert result.super_large_net == 5e8
        provider.get_capital_flow.assert_awaited_once_with("600519")

    def test_get_valuation_with_code_parameter(self):
        provider = _make_provider(
            get_valuation=AsyncMock(return_value=Valuation(
                pe_ttm=28.5, pb=8.2,
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_valuation("600519"))
        assert result is not None
        assert result.pe_ttm == 28.5
        provider.get_valuation.assert_awaited_once_with("600519")

    def test_get_financial_data_with_code_parameter(self):
        provider = _make_provider(
            get_financial_data=AsyncMock(return_value=FinancialData(
                net_profit=5.5e10, roe=30.5,
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_financial_data("600519"))
        assert result is not None
        assert result.roe == 30.5
        provider.get_financial_data.assert_awaited_once_with("600519")


class TestRaceProvidersFallback:
    """Verify _race_providers falls through to next provider on failure"""

    def test_first_provider_fails_second_succeeds(self):
        failing = _make_provider(
            get_indices=AsyncMock(side_effect=ConnectionError("timeout")),
        )
        succeeding = _make_provider(
            get_indices=AsyncMock(return_value=[
                IndexData(name="上证指数", code="000001", close=3200.0),
            ]),
        )
        manager = DataProviderManager([failing, succeeding])

        result = asyncio.run(manager.get_indices())
        assert len(result) == 1
        assert result[0].name == "上证指数"

    def test_all_providers_fail_returns_none_for_indices(self):
        failing1 = _make_provider(
            get_indices=AsyncMock(side_effect=ConnectionError("fail")),
        )
        failing2 = _make_provider(
            get_indices=AsyncMock(side_effect=RuntimeError("error")),
        )
        manager = DataProviderManager([failing1, failing2])

        result = asyncio.run(manager.get_indices())
        assert result == []

    def test_all_providers_fail_returns_empty_for_sector_rankings(self):
        failing = _make_provider(
            get_sector_rankings=AsyncMock(side_effect=Exception("fail")),
        )
        manager = DataProviderManager([failing])

        top, bottom = asyncio.run(manager.get_sector_rankings())
        assert top == []
        assert bottom == []

    def test_all_providers_fail_returns_default_for_market_statistics(self):
        failing = _make_provider(
            get_market_statistics=AsyncMock(side_effect=Exception("fail")),
        )
        manager = DataProviderManager([failing])

        result = asyncio.run(manager.get_market_statistics())
        assert result.up_count == 0
        assert result.down_count == 0

    def test_first_provider_raises_second_returns_data(self):
        provider1 = _make_provider(
            get_chip_distribution=AsyncMock(side_effect=RuntimeError("unsupported")),
        )
        provider2 = _make_provider(
            get_chip_distribution=AsyncMock(return_value=ChipDistribution(
                profit_ratio=75.0, avg_cost=1720.0,
            )),
        )
        manager = DataProviderManager([provider1, provider2])

        result = asyncio.run(manager.get_chip_distribution("600519"))
        assert result is not None
        assert result.profit_ratio == 75.0


class TestRaceProvidersPriority:
    """Verify _race_providers returns the first successful result (priority order)"""

    def test_first_provider_wins(self):
        provider1 = _make_provider(
            get_indices=AsyncMock(return_value=[
                IndexData(name="上证指数", code="000001", close=3200.0),
            ]),
        )
        provider2 = _make_provider(
            get_indices=AsyncMock(return_value=[
                IndexData(name="上证指数", code="000001", close=3300.0),
            ]),
        )
        manager = DataProviderManager([provider1, provider2])

        result = asyncio.run(manager.get_indices())
        assert result[0].close == 3200.0


class TestGetStockInfoFallback:
    """Verify get_stock_info returns StockInfo(code=code, name=code) on total failure"""

    def test_all_providers_fail_returns_fallback(self):
        failing = _make_provider(
            get_stock_info=AsyncMock(side_effect=Exception("fail")),
        )
        manager = DataProviderManager([failing])

        result = asyncio.run(manager.get_stock_info("600519"))
        assert result.code == "600519"
        assert result.name == "600519"


class TestGetRealtimeQuoteFallback:
    """Verify get_realtime_quote returns empty RealtimeQuote on failure"""

    def test_all_providers_fail_returns_empty(self):
        failing = _make_provider(
            get_realtime_quote=AsyncMock(side_effect=Exception("fail")),
        )
        manager = DataProviderManager([failing])

        result = asyncio.run(manager.get_realtime_quote("600519"))
        assert result.price == 0.0

    def test_price_zero_treated_as_failure(self):
        provider = _make_provider(
            get_realtime_quote=AsyncMock(return_value=RealtimeQuote(price=0.0)),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_realtime_quote("600519"))
        assert result.price == 0.0


class TestGetDailyDataBestResult:
    """Verify get_daily_data picks the result with the most rows"""

    def test_selects_longest_dataframe(self):
        df_short = pd.DataFrame({"close": [10.0, 11.0, 12.0]})
        df_long = pd.DataFrame({"close": range(20)})

        provider1 = _make_provider(
            get_daily_data=AsyncMock(return_value=df_short),
        )
        provider2 = _make_provider(
            get_daily_data=AsyncMock(return_value=df_long),
        )
        manager = DataProviderManager([provider1, provider2])

        result = asyncio.run(manager.get_daily_data("600519"))
        assert len(result) == 20

    def test_all_providers_fail_returns_empty_df(self):
        failing = _make_provider(
            get_daily_data=AsyncMock(side_effect=Exception("fail")),
        )
        manager = DataProviderManager([failing])

        result = asyncio.run(manager.get_daily_data("600519"))
        assert result.empty


class TestGetMarketOverview:
    """Verify get_market_overview aggregates results from sub-methods"""

    def test_aggregates_parallel_results(self):
        provider = _make_provider(
            get_indices=AsyncMock(return_value=[
                IndexData(name="上证指数", code="000001", close=3200.0),
            ]),
            get_market_statistics=AsyncMock(return_value=MarketStatistics(
                up_count=3000, down_count=1500,
            )),
            get_sector_rankings=AsyncMock(return_value=(
                [SectorData(name="半导体", change_pct=3.2)],
                [SectorData(name="房地产", change_pct=-1.5)],
            )),
        )
        manager = DataProviderManager([provider])

        result = asyncio.run(manager.get_market_overview())
        assert len(result.indices) == 1
        assert result.statistics.up_count == 3000
        assert len(result.top_sectors) == 1
        assert len(result.bottom_sectors) == 1


path: test/test_models.py
content: from __future__ import annotations

import pytest

from src.models import (
    CapitalFlow,
    CheckItem,
    ChipDistribution,
    FinancialData,
    IndexData,
    MarketAnalysisResult,
    MarketStatistics,
    NewsItem,
    RealtimeQuote,
    SectorData,
    StockAnalysisResult,
    StockInfo,
    TechnicalIndicators,
    Valuation,
)


class TestStockInfo:
    def test_defaults(self):
        info = StockInfo(code="600519", name="贵州茅台")
        assert info.code == "600519"
        assert info.name == "贵州茅台"
        assert info.industry == ""
        assert info.market_cap == 0.0

    def test_full_init(self):
        info = StockInfo(code="000001", name="平安银行", industry="银行", market_cap=1e12)
        assert info.industry == "银行"
        assert info.market_cap == 1e12


class TestRealtimeQuote:
    def test_defaults(self):
        q = RealtimeQuote()
        assert q.price == 0.0
        assert q.change_pct == 0.0
        assert q.turnover_rate == 0.0


class TestTechnicalIndicators:
    def test_bullish_alignment_default(self):
        tech = TechnicalIndicators()
        assert tech.is_bullish_alignment is False


class TestChipDistribution:
    def test_defaults(self):
        chip = ChipDistribution()
        assert chip.profit_ratio == 0.0
        assert chip.avg_cost == 0.0


class TestCapitalFlow:
    def test_defaults(self):
        cf = CapitalFlow()
        assert cf.super_large_net == 0.0
        assert cf.ddx == 0.0


class TestStockAnalysisResult:
    def test_defaults(self):
        result = StockAnalysisResult()
        assert result.stock_code == ""
        assert result.score == 0
        assert result.checklist == []
        assert result.risk_alerts == []
        assert result.disclaimer != ""

    def test_with_data(self):
        result = StockAnalysisResult(
            stock_code="600519",
            stock_name="贵州茅台",
            score=75,
            action="买入",
            trend="看多",
        )
        assert result.stock_code == "600519"
        assert result.score == 75
        assert result.action == "买入"


class TestMarketAnalysisResult:
    def test_defaults(self):
        result = MarketAnalysisResult()
        assert result.date == ""
        assert result.indices == []
        assert result.sentiment == ""
        assert result.disclaimer != ""


class TestMarketStatistics:
    def test_defaults(self):
        stats = MarketStatistics()
        assert stats.up_count == 0
        assert stats.down_count == 0
        assert stats.limit_up_count == 0


class TestCheckItem:
    def test_init(self):
        item = CheckItem(condition="MA5 > MA10", status="✅", detail="均线多头")
        assert item.condition == "MA5 > MA10"
        assert item.status == "✅"


class TestNewsItem:
    def test_init(self):
        item = NewsItem(title="利好消息", snippet="摘要", date="2025-01-15")
        assert item.title == "利好消息"
        assert item.info_type == ""


path: test/test_utils.py
content: from __future__ import annotations

import time

import numpy as np
import pandas as pd
import pytest

from src.data.utils import (
    RealtimeCache,
    calculate_chip_from_daily,
    safe_float,
)


class TestSafeFloat:
    def test_normal_value(self):
        assert safe_float(3.14) == 3.14

    def test_string_value(self):
        assert safe_float("2.5") == 2.5

    def test_none_returns_default(self):
        assert safe_float(None) == 0.0
        assert safe_float(None, default=-1.0) == -1.0

    def test_empty_string_returns_default(self):
        assert safe_float("") == 0.0

    def test_dash_returns_default(self):
        assert safe_float("-") == 0.0

    def test_invalid_string_returns_default(self):
        assert safe_float("abc") == 0.0

    def test_zero_value(self):
        assert safe_float(0) == 0.0

    def test_negative_value(self):
        assert safe_float(-5.5) == -5.5


class TestRealtimeCache:
    def test_empty_cache_returns_none(self):
        cache = RealtimeCache(ttl=600)
        assert cache.get() is None

    def test_set_and_get(self):
        cache = RealtimeCache(ttl=600)
        df = pd.DataFrame({"close": [10.0, 20.0]})
        cache.set(df)
        result = cache.get()
        assert result is not None
        assert len(result) == 2

    def test_expired_cache_returns_none(self):
        cache = RealtimeCache(ttl=0)
        df = pd.DataFrame({"close": [10.0]})
        cache.set(df)

        cache._timestamp = time.time() - 10
        assert cache.get() is None


class TestCalculateChipFromDaily:
    def test_none_df(self):
        assert calculate_chip_from_daily(None) is None

    def test_empty_df(self):
        df = pd.DataFrame()
        assert calculate_chip_from_daily(df) is None

    def test_insufficient_data(self):
        df = pd.DataFrame({"close": [10.0, 11.0], "volume": [100, 200]})
        assert calculate_chip_from_daily(df) is None

    def test_valid_data(self):
        df = pd.DataFrame({
            "close": [10.0] * 5 + [12.0] * 5 + [15.0] * 5,
            "volume": [1000] * 15,
        })
        result = calculate_chip_from_daily(df)
        assert result is not None
        assert isinstance(result.profit_ratio, float)
        assert isinstance(result.avg_cost, float)
        assert isinstance(result.concentration, float)

    def test_zero_volume(self):
        df = pd.DataFrame({
            "close": [10.0] * 15,
            "volume": [0] * 15,
        })
        assert calculate_chip_from_daily(df) is None


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