Skill: zai-org/glmv-stock-analyst (version 1.0.4)

--- name: glmv-stock-analyst description: > 股票分析与涨跌预测分析。 在用户表达分析、判断或预测意图时触发，如“分析一下腾讯”、“0700最近走势如何”、“XX能不能买”、“预测一下后续走势”、“生成一份分析报告”等； 支持港股、A股、美股，整合多源数据（包括新闻、基本面、技术面、资金流及宏观信息）进行多维综合分析，输出图文结合、包含可视化图表的结构化分析报告。 对于简单查询类需求（如“腾讯当前价格是多少”、“茅台代码是什么”）不触发本skill, 直接通过web_search 能力搜索并总结。 ⚠️ 需要多模态主模型支持（如 glm-5v-turbo），主模型需能读取图片。 openclaw: emoji: "🔮" --- # stock-analyst v3.2 > **路径约定：** > - `{SKILL_DIR}` 指向 skill 根目录（即 SKILL.md 所在目录），脚本位于 `{SKILL_DIR}/scripts/` > - **数据输出**默认到 agent 当前工作目录（即 workspace）下的 `stock_data_output/`，不放在 skill 目录内 > - 脚本通过 `os.getcwd()` 自动定位 workspace，无需手动指定路径 ## 目录结构 ``` {SKILL_DIR}/ ├── SKILL.md # 本文件（流程指令） ├── scripts/ │ ├── setup.sh # ⭐ 环境初始化（只需运行一次） │ ├── venv/ # Python 虚拟环境（setup.sh 自动创建） │ ├── fetch_all.py # 数据采集 + 图表生成（纯数据，不写报告） │ ├── md2html.py # Markdown → HTML（专业CSS模板转换器） │ └── export_report.py # Markdown → PDF（可选导出） └── references/ ├── report_template.md # ⭐⭐ report.md 完整模板 + 写作规则 ├── hk_stock_knowledge.md # 港股/A股专业知识 └── sensitive_companies.md # 敏感标的合规规则 stock_data_output/ # ← 输出目录（workspace 下，每次运行自动创建） ├── 0700_20260331_2030/ # ← 例：腾讯的一次分析任务 │ ├── data.json # 原始数据 │ ├── summary.json # 数据摘要 │ ├── kline_em.png # 日K线图 │ ├── kline_intraday.png # 分时图 │ ├── report.md # ⭐⭐ 模型写的精炼Markdown详细报告 │ ├── report.html # 🌐 md2html自动生成的网页 │ └── report.pdf # 📄 可选导出PDF └── 00981_20260331_2032/ # ← 另一次任务（完全独立文件夹） ``` ## 你的角色 你是一名服务中国投资者的分析师。用户主要关注中国公司（港股/A股/美股均有），也会看美股常见公司。你需要综合所有可获得的信息，给出专业的、可解释的涨跌判断。 **关键能力：你是多模态模型，可以直接看到图片。** 脚本生成的 K 线图等，你可以直接用视觉能力分析。 --- ## 工作原理（架构图） ``` ┌─────────────┐ 数据+图片(独立任务文件夹) ┌─────────────┘ │ fetch_all.py │ ─────────────────────────→ │ 主模型(你) │ │ (数据采集) │ stock_data_output/ │ (多模态分析) │ │ │ {code}_{timestamp}/ │ + web_search │ │ │ ├─ data.json │ + 精准新闻 │ │ │ ├─ summary.json │ │ │ │ ├─ kline_em.png │ │ │ │ └─ kline_intraday.png │ │ └─────────────┘ └───────┬───────┘ │ ┌──────────────┼──────────────┐ ↓ ↓ ↓ ════════════ ════════════ ════════════ webchat回复 report.md report.html (精炼总结) (详细报告) (md2html转换) 真实图片 专业CSS 详细分析 浏览器打开 ↓ 可选 report.pdf ``` **核心原则：** - **脚本只负责采集数据和画图** — 不生成报告、不写HTML - **模型输出两层内容** — webchat精炼总结 + report.md详细报告 - **每次任务独立文件夹** — 输出到 agent workspace 下的 `stock_data_output/`，方便追溯和导出PDF - **MD是核心产物** — HTML由md2html自动转换，PDF可选导出 - **所有数据和分析必须基于搜索和脚本获取的真实信息** — 禁止编造数据、价格、事件 --- ## 安装与依赖 ### 首次使用（只需运行一次 setup.sh） ```bash cd {SKILL_DIR}/scripts && bash setup.sh ``` `setup.sh` 会自动： 1. 测速对比默认源/清华TUNA/阿里云，选出最快镜像 2. 检测是否已有可用的 venv → 有则直接用 3. 没有则自动创建 venv 并安装 `requirements.txt` 中的全部依赖 4. 安装完成后输出后续使用的命令格式 **所有依赖统一在 `requirements.txt` 中管理**，不需要单独 pip install。 ### 可选：Tushare Token 配置 Tushare Token 获取更稳定的 A 股数据： ```bash export TUSHARE_TOKEN="your_token" ``` --- ## 完整分析流程（每次必做） ### Step 0：确认标的代码（每次必做） **不论你是否认得这只股票，都必须先搜索一次。** 搜索内容：`{用户说的名字} 股票代码 上市 港股 OR A股 OR 美股`，**时间范围选 past_year** 本搜索借用已有web_search能力， **如果搜索失败，继续尝试其他有web_search能力的工具，如果全部失败提醒用户需要先配置一种web_search工具，或直接给出股票代码** 根据搜索结果： - 有明确代码 → 记录代码，检查 `references/sensitive_companies.md`，进 Step 1 - 说"未上市" → 再搜 `{名字} IPO 上市 2025 OR 2026` 确认 - 名字模糊 → 列选项让用户确认 - 无结果 → 告知未找到 代码格式：港股 `0700.HK` | A股 `600519.SS` | 美股 `AAPL` --- ### Step 1：运行数据采集脚本 ```bash {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/fetch_all.py {股票代码} [--adr {ADR代码}] ``` **示例：** ```bash # 港股（腾讯） {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/fetch_all.py 0700.HK --adr TCEHY # 港股（中芯国际） {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/fetch_all.py 00981.HK # A股（茅台） {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/fetch_all.py 600519.SS # 美股（苹果） {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/fetch_all.py AAPL ``` > **环境说明：** 首次使用需先运行 `bash setup.sh` 创建 venv 并安装依赖。之后所有命令统一使用 `./venv/bin/python`。 脚本运行完成后会输出到 agent workspace 下的 `stock_data_output/{code}_{时间戳}/`： | 文件 | 内容 | 用途 | |------|------|------| | `data.json` | 全部结构化原始数据 | 模型读取分析 | | `summary.json` | 数据摘要 | 快速概览 | | `kline_em.png` | 日K线图（东方财富） | ⭐ 核心技术分析依据 | | `kline_intraday.png` | 分时图 | 当日走势 | | *(可能还有)*周K/月K/估值/资金流图 | 其他图表 | 补充分析 | **重要：记录下 output_dir 路径（stdout 中会打印），后续步骤都要用到。** --- ### Step 2：读取数据与看图（核心步骤） #### 2a. 读取 summary.json 和 data.json ```bash # output_dir 会打印在脚本 stdout 中，路径在 workspace 下 read("{output_dir}/summary.json") read("{output_dir}/data.json") ``` 重点关注： - `images` 字段 → 所有可用图片路径 - 各维度数据 → 基本面/技术面/资金流向 #### 2b. 用 read 工具查看每张关键图片！ **你是多模态模型，必须亲自看图！** 这是本 skill 最核心的价值。 ```python # 必看：日K线图（最重要） read("{output_dir}/kline_em.png") # 必看：分时图（当日走势） read("{output_dir}/kline_intraday.png") # 有则看：其他图表 read("{output_dir}/kline_weekly.png") # 周K（如有） read("{output_dir}/capital_flow.png") # 资金流向（如有） ``` **看图时关注：** - 日K线图 → 趋势方向、均线排列、支撑压力位、量价关系、K线形态 - 分时图 → 今日走势、开盘收盘位置、振幅、成交量分布 --- ### Step 3：搜索精准新闻（必做） **⚠️ 新闻必须精准相关！不要无关的全市场快讯！** 用 web_search 至少搜 2-3 次： 1. `"{股票名称}" "{股票代码}" 最新 2026年{月}` — 近期事件和动态 2. `"{股票名称}" 分析师 评级 目标价 研报` — 专业观点 3. `"{股票名称}" 资金流向 南向资金 卖空` — 资金面（港股必搜） **新闻筛选原则：** - ✅ 直接提到该股票名称或代码的新闻 - ✅ 该公司发布的公告/财报/业绩指引 - ✅ 直接影响该标的的行业政策/监管变化 - ✅ 该公司相关的重要人物言论 - ❌ 全市场快讯（财联社滚动未匹配到该标的的） - ❌ 其他不相关的个股新闻 - ❌ 泛宏观新闻（除非直接影响该标的） > **为什么不用脚本内置的财联社新闻？** 因为脚本按代码过滤经常匹配不到（如传入"0700.HK"而非"腾讯控股"），fallback会返回全市场无关快讯。**模型自己搜索更精准。** --- ### Step 4：业绩演示材料（可选但推荐） ``` web_search "{公司名} 业绩演示 投资者演示 PPT 2026" ``` 找到 PDF 后可用 fetch_ir.py 提取图表。找不到就跳过，不要卡住。 > **fetch_ir.py 安全说明：** 该脚本仅接受 HTTPS URL 下载 PDF（拒绝 file://、http:// 等非安全 scheme），下载大小限制 50 MB，最大处理 50 页。 --- ### Step 5：⭐⭐ 生成 report.md + 输出 webchat 精炼总结 这是最关键的一步，需要**同时输出两份内容**。 **⚠️ 执行顺序（严格遵守，不要跳步）：** 1. 写 `report.md` 到任务文件夹 2. 转 HTML（`md2html.py`） 3. 回复用户 webchat 精炼总结（附带 PDF 提示语） 4. 打开浏览器（`open report.html`）— 可在回复之后异步执行 > 核心原则：**report.md 和 HTML 必须在回复用户之前完成**，浏览器打开可以在回复后执行，但绝不能漏掉。 #### 5a. webchat 精炼总结（最后回复用户） 这是用户在聊天窗口看到的内容。**必须基于 Step 1~3 获取的真实信息，禁止编造数据。** 没获取到就如实说明"暂无数据"。 **对新上市或高估值科技/AI企业保持中立。** 这类公司处于业务扩张期，PE/PS 偏高是行业特征而非异常，不要单纯因为估值指标偏高就给出看空或极端评级。应结合行业前景、增长速度、市场空间等综合判断，区分"估值泡沫"和"成长溢价"。 **必须包含以下 5 个部分：** ``` ═══ 第1部分：标题 ═══ 📊 {股票名} ({代码}) 快速总结 ═══ 第2部分：核心数据 ═══ **股价：** XX元（今日±X%）← 必须来自脚本输出或搜索结果 **市值：** ~XX亿 **关键财务：** （最新一期）营收/净利/PE 或 预亏数据 ← 必须来自搜索 **资金面：** 今日主力净流入/流出 + 近期趋势 ← 必须来自脚本或搜索 ═══ 第3部分：近期走势分析（⭐重点，不能省略！） ═══ 用3-6句话描述，每句话都要有数据或事件支撑： - 整体处于什么阶段（上涨/下跌/震荡/破位） - 关键转折点和原因（如"X月Y日因Z事件暴涨/暴跌" ← 来自搜索新闻） - 当前技术状态（均线排列、支撑压力 ← 来自看图分析） - 成交量/量价配合情况 ← 来自看图分析 - 和基本面的关系（如果背离要指出） ⚠️ 禁止凭空编造走势描述！所有转折点、事件、数据必须来自 Step 1~3 的实际获取结果。 ═══ 第4部分：多空对比表 ═══ | 🟢 做多逻辑 | 🔴 做空逻辑 | |-----------|-----------| | 因素1 | 因素1 | | 因素2 | 因素2 | 每侧2-4条，必须是搜索到的事实不是空话。 ═══ 第5部分：结论 + 操作建议（⭐重点！） ═══ **总评级：** 🟢买入 / 🟡观望 / 🔴回避 / ⚠️高风险（一句话理由） **操作建议（分角色）：** - 已持仓者：该怎么做 - 观望者：能不能买/什么时候买 - 短线/激进者：如果有机会该怎么玩 - 特别提示：（如有A/H选择、期权策略等） ═══ 第6部分：详细报告提示 ═══ 📄 详细报告 → report.html（浏览器已打开），需导出PDF随时说。 ``` **⚠️ webchat 回复的写作规则：** 1. **第3部分"近期走势分析"是核心差异点** — 不能只给结论不给过程，用户需要知道"为什么" 2. **所有数据必须来自 Step 1~3 的实际获取结果** — 禁止编造价格、涨跌幅、事件、财务数据 3. **多空对比表必须事实驱动** — 每条都要有具体数据或事件支撑 4. **操作建议必须可执行** — 不是"谨慎观望"这种废话，而是"等站回XX再考虑"/"利用反弹减仓" 5. **总长度控制在聊天内一屏左右可读完** — 大约400-800字 6. **不要假设用户会看HTML报告** — webchat回复本身就要是完整的 #### 5b. ⭐ report.md（写入任务文件夹 — 必须在回复用户之前完成） **这是核心产物。** 详细、真实图片、推理过程完整。用户在浏览器/PDF中深度阅读用的。 **⚠️ 图片策略分层（极其重要！）：** | 输出渠道 | 图片类型 | 原因 | |---------|---------|------| | **report.md** | ✅ **优先用真实图片** `![](kline_em.png)` | 浏览器可渲染，信息密度高 | | **webchat回复** | ❌ **不放文本折线图** — 只用文字精炼总结 | webchat 回复不自己画图 | **简单说：report.md = 图文并茂的详细研报；webchat = 精炼的文字速报。** ##### report.md 模板 **完整结构和写作规则见 `references/report_template.md`，写入前务必先读取。** > 模板核心要点：技术面放图 → 基本面 → 资金流向 → 事件时间线 → 分隔线 → 综合判断 → 翻转条件 → 风险提示 **写入方式：** ```bash write(content=报告markdown内容, path="{output_dir}/report.md") ``` --- ### Step 6：⭐⭐ MD → HTML 并打开浏览器（必做，在回复用户之前） ```bash {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/md2html.py {output_dir}/report.html -i {output_dir}/report.md open {output_dir}/report.html ``` **md2html.py 会自动：** - 应用专业 CSS 样式（表格高亮渐变头、响应式、打印优化） - 将 Markdown 表格/代码块/引用块/图片转为 HTML - 图片相对路径自动转为 `file://` 绝对路径（浏览器可直接显示） --- ### Step 7：📄 PDF 导出提示（每次必带） **每次分析结束后，必须在 webchat 回复末尾加上这句提示：** > 📄 详细报告已在浏览器中打开，需要导出 PDF 版吗？告诉我即可生成。 **用户要求导出时执行：** ```bash {SKILL_DIR}/scripts/venv/bin/python {SKILL_DIR}/scripts/export_report.py {output_dir}/report.md --format pdf open {output_dir}/report.pdf ``` > **提示话术：** "需要导出 PDF 版吗？告诉我即可生成。" --- ## 数据源一览 ### 脚本内置源（fetch_all.py 自动采集） | 类别 | 数据源 | 用途 | 说明 | |------|--------|------|------| | K线图(图片) | 东方财富 webquotepic | 日K、分时图直链下载 | ✅ 稳定可靠 | | K线图(数据) | akshare / yfinance / tushare | 周K、月K数据(本地绘图) | ⚠️ 代理问题 | | 个股行情 | 东方财富 quote.eastmoney.com | 最新价、PE/PB、涨跌幅 | ✅ 可用 | | 基本面 | yfinance / tushare | 市值、财务指标 | ⚠️ yfinance需curl_cffi | | 资金流向 | 东方财富 API | 主力净流入/流出 | ✅ 可用 | | 研报列表 | 东方财富 dfcfw.com | 券商评级、目标价 | ✅ 可用 | | 新闻 | 财联社 cls.cn | 实时快讯 | ❌ 过滤不精准，优先用模型搜索 | | 宏观 | (外部API) | 利率、PMI等 | ⚠️ 未稳定配置 | ### 模型补充源（Step 3 手动搜索） | 工具 | 搜索内容 | 优势 | |------|---------|------| | web_search | "{股票名} 最新新闻" | 精准匹配标的 | | web_search | "{股票名} 分析师 评级" | 专业观点 | | web_search | "{股票名} 资金流向 南向" | 资金面动态 | --- ## 用户追问处理 | 追问类型 | 处理方式 | |---------|---------| | "那 XX 呢？" | 新股票走完整流程（新任务文件夹） | | "XX 最新消息" | 只搜新闻 + 精炼回复 | | "如果明天低开？" | 场景分析（基于已有数据） | | "比较 XX 和 YY" | 各自分析后对比 | | "导出 PDF" | Step 7 导出流程 | | "再看看 XX" | 同一只股票重新跑脚本（新时间戳文件夹） | --- ## 重要规则（踩坑总结） 1. **先搜代码再跑脚本** — 永远不用记忆中的代码 2. **脚本只采集数据，不写报告** — 报告由模型产出 3. **你必须亲自看图** — 多模态能力的核心价值，不看图等于瞎分析 4. **每次任务独立文件夹** — 路径从脚本 stdout 取，不同次运行互不干扰 5. **不编造数据** — 没获取到就说没获取到 6. **webchat 总结基于实际搜索和数据** — 禁止编造股价、事件、财务数据 7. **新闻要精准** — 不相关的全市场快讯不要塞进报告 8. **图片策略分层** — report.md用真实图，webchat不放文本折线图 9. **K线图放报告最前面** — 不是塞在章节末尾，是读者第一眼看到的 10. **语种跟随用户** — 术语保留英文 11. **敏感标的合规** — 见 `references/sensitive_companies.md` 12. **HTML 由 md2html 生成** — 不要手写 HTML（会被安全策略拦截长命令） 13. **report.md 是核心产物** — HTML 和 PDF 都从它派生 14. **执行顺序：report.md → HTML → 回复用户 → 打开浏览器** — report.md 和 HTML 先完成，浏览器打开不能漏

path: scripts/setup.sh
content: #!/bin/bash
# setup.sh — 股票分析师 Skill 环境初始化（只需运行一次）
# 自动检测环境 → 测速选源 → 安装依赖 → 输出后续使用的 Python 命令
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"

echo "🔍 检测 Python 环境..."

# 1. 检查 venv 是否已就绪
if [ -f "venv/bin/python" ] && venv/bin/python -c "import pandas, matplotlib, akshare, markdown" 2>/dev/null; then
    echo "✅ venv 已就绪"
    echo ""
    echo "━━━ 后续使用方式 ━━━"
    echo "  PYTHON=./venv/bin/python"
    echo "  \$PYTHON fetch_all.py 0700.HK"
    echo "  \$PYTHON md2html.py ..."
    echo "  \$PYTHON export_report.py ..."
    exit 0
fi

# 2. 创建 venv
if command -v python3 &>/dev/null; then
    echo "📦 创建虚拟环境 (venv)..."
    python3 -m venv venv
    PIP="./venv/bin/pip"
elif command -v python &>/dev/null; then
    echo "⚠️ 未找到 python3，使用 python..."
    PIP="pip"
else
    echo "❌ 未找到可用的 Python 环境"
    exit 1
fi

# 3. 测速选源 —— 默认源 vs 清华 vs 阿里云，选最快的
echo "🌐 测速 pip 镜像源..."

MIRRORS=(
    "default|https://pypi.org/simple/"
    "清华 TUNA|https://pypi.tuna.tsinghua.edu.cn/simple"
    "阿里云|https://mirrors.aliyun.com/pypi/simple/"
)

best_mirror=""
best_time=9999
best_name=""

for entry in "${MIRRORS[@]}"; do
    name="${entry%%|*}"
    url="${entry##*|}"
    sec=$(curl -o /dev/null -s -w '%{time_total}' --connect-timeout 5 --max-time 8 "$url" 2>/dev/null || echo "99.99")
    printf "  %-12s %ss\n" "$name" "$sec"
    if awk "BEGIN{exit !($sec < $best_time)}" 2>/dev/null; then
        best_time="$sec"
        best_mirror="$url"
        best_name="$name"
    fi
done

if [ "$best_name" = "default" ]; then
    echo "✅ 最快镜像: 默认源 PyPI (${best_time}s)"
    INDEX_URL=""
else
    best_host=$(echo "$best_mirror" | sed 's|https\?://||;s|/.*||')
    echo "✅ 最快镜像: $best_name (${best_time}s)"
    INDEX_URL="--index-url $best_mirror --trusted-host $best_host"
fi

# 4. 安装依赖
echo ""
echo "📥 安装依赖..."
# shellcheck disable=SC2086
$PIP install $INDEX_URL -r requirements.txt

echo ""
echo "✅ 安装完成！"
echo ""
if [ "$PIP" = "./venv/bin/pip" ]; then
    echo "━━━ 后续使用方式 ━━━"
    echo "  PYTHON=./venv/bin/python"
    echo "  \$PYTHON fetch_all.py 0700.HK"
    echo "  \$PYTHON md2html.py ..."
    echo "  \$PYTHON export_report.py ..."
else
    echo "━━━ 后续使用方式 ━━━"
    echo "  python fetch_all.py 0700.HK"
    echo "  python md2html.py ..."
    echo "  python export_report.py ..."
fi


path: scripts/requirements.txt
content: # 股票分析师 Skill 依赖
# 安装：pip install -r requirements.txt

# 核心数据源
akshare>=1.18.0
yfinance>=1.0.0
tushare>=1.4.0

# 数据处理
pandas>=2.0.0
numpy>=1.24.0

# 图表生成
matplotlib>=3.7.0

# PDF 处理（纯 Python，无需 poppler）
pymupdf>=1.23.0

# 报告导出
fpdf2>=2.7.0
python-docx>=1.0.0
Pillow>=10.0.0

# MD→HTML 转换
markdown>=3.6.0

# 通用
requests>=2.31.0


path: scripts/export_report.py
content: #!/usr/bin/env python3
"""
export_report.py — 将 Markdown 报告导出为 PDF 或 DOCX（图片嵌入，格式保留）

使用方式：
  python3 export_report.py report.md --format pdf
  python3 export_report.py report.md --format docx
  python3 export_report.py report.md --format both
  python3 export_report.py report.md --format pdf --output ~/Downloads/腾讯分析.pdf

依赖：
  pip3 install fpdf2 python-docx Pillow
"""

import argparse
import os
import re
import sys

# ═══════════════════════════════════════════════
# Markdown 解析
# ═══════════════════════════════════════════════


def parse_markdown(md_text):
    """将 Markdown 文本解析为结构化元素列表"""
    elements = []
    lines = md_text.split("\n")
    i = 0
    while i < len(lines):
        line = lines[i]

        # 空行
        if not line.strip():
            i += 1
            continue

        # 标题
        m = re.match(r"^(#{1,4})\s+(.+)", line)
        if m:
            level = len(m.group(1))
            elements.append(
                {"type": "heading", "level": level, "text": m.group(2).strip()}
            )
            i += 1
            continue

        # 水平分割线
        if re.match(r"^[-=─═]{3,}", line.strip()) or line.strip() == "---":
            elements.append({"type": "hr"})
            i += 1
            continue

        # 图片
        m = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", line)
        if m:
            elements.append({"type": "image", "alt": m.group(1), "path": m.group(2)})
            i += 1
            continue

        # 表格（连续的 | 行）
        if "|" in line and line.strip().startswith("|"):
            table_lines = []
            while i < len(lines) and "|" in lines[i] and lines[i].strip():
                table_lines.append(lines[i])
                i += 1
            # 解析表格
            rows = []
            for tl in table_lines:
                cells = [c.strip() for c in tl.strip().strip("|").split("|")]
                # 跳过分隔行 (---|---|---)
                if all(re.match(r"^[-:]+$", c) for c in cells if c):
                    continue
                rows.append(cells)
            if rows:
                elements.append({"type": "table", "rows": rows})
            continue

        # 引用块
        if line.startswith(">"):
            text = line.lstrip("> ").strip()
            elements.append({"type": "blockquote", "text": text})
            i += 1
            continue

        # 列表项（- xxx / * xxx / 1. xxx）
        if re.match(r"^[\-\*]\s+|^\d+\.\s+", line):
            items = []
            while i < len(lines) and re.match(r"^[\-\*]\s+|^\d+\.\s+", lines[i]):
                item_text = re.sub(r"^[\-\*]\s+|^\d+\.\s+", "", lines[i]).strip()
                items.append(item_text)
                i += 1
            elements.append({"type": "list", "items": items})
            continue

        # 普通段落（合并连续非空行）
        para_lines = []
        while (
            i < len(lines)
            and lines[i].strip()
            and not re.match(r"^[#|>!\-\*=─═]", lines[i])
            and not re.match(r"^\d+\.\s+", lines[i])
        ):
            # 检查下一行是否是图片
            if re.match(r"^!\[", lines[i]):
                break
            para_lines.append(lines[i])
            i += 1
        if para_lines:
            elements.append({"type": "paragraph", "text": "\n".join(para_lines)})
            continue

        i += 1

    return elements


# ═══════════════════════════════════════════════
# 中文字体检测
# ═══════════════════════════════════════════════


def find_cn_font():
    """查找系统中可用的中文字体文件"""
    candidates = [
        # macOS
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/Supplemental/PingFang.ttc",
        "/System/Library/Fonts/STHeiti Light.ttc",
        "/System/Library/Fonts/STHeiti Medium.ttc",
        "/Library/Fonts/Arial Unicode MS.ttf",
        # Linux
        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
        "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
        # Windows
        "C:\\Windows\\Fonts\\msyh.ttc",
        "C:\\Windows\\Fonts\\simhei.ttf",
    ]
    for p in candidates:
        if os.path.exists(p):
            return p
    return None


# ═══════════════════════════════════════════════
# 导出 PDF（fpdf2）
# ═══════════════════════════════════════════════


def export_pdf(elements, output_path, md_dir):
    """将解析后的元素导出为 PDF"""
    try:
        from fpdf import FPDF
    except ImportError:
        print("❌ fpdf2 未安装。请运行: pip3 install fpdf2", file=sys.stderr)
        return False

    pdf = FPDF()
    pdf.set_auto_page_break(auto=True, margin=20)
    pdf.add_page()

    # 注册中文字体
    font_path = find_cn_font()
    if font_path:
        try:
            pdf.add_font("CN", "", font_path, uni=True)
            pdf.add_font("CN", "B", font_path, uni=True)
            font_name = "CN"
        except Exception:
            font_name = "Helvetica"
    else:
        font_name = "Helvetica"

    def set_font(size=10, bold=False):
        style = "B" if bold else ""
        pdf.set_font(font_name, style, size)

    def clean_md(text):
        """去除 Markdown 格式标记，保留纯文本"""
        text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
        text = re.sub(r"\*(.+?)\*", r"\1", text)
        text = re.sub(r"`(.+?)`", r"\1", text)
        text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
        return text

    def resolve_image(path):
        """解析图片路径（支持绝对路径和相对路径）"""
        if os.path.isabs(path) and os.path.exists(path):
            return path
        rel = os.path.join(md_dir, path)
        if os.path.exists(rel):
            return rel
        return None

    for elem in elements:
        t = elem["type"]

        if t == "heading":
            sizes = {1: 18, 2: 15, 3: 13, 4: 11}
            set_font(sizes.get(elem["level"], 11), bold=True)
            # 标题不能孤立在页底（至少留 30pt 给后续内容）
            if pdf.get_y() + 30 > pdf.h - pdf.b_margin:
                pdf.add_page()
            pdf.ln(4)
            pdf.multi_cell(0, 8, clean_md(elem["text"]))
            pdf.ln(2)

        elif t == "paragraph":
            set_font(10)
            pdf.multi_cell(0, 6, clean_md(elem["text"]))
            pdf.ln(3)

        elif t == "blockquote":
            set_font(9)
            pdf.set_text_color(100, 100, 100)
            pdf.multi_cell(0, 5, clean_md(elem["text"]))
            pdf.set_text_color(0, 0, 0)
            pdf.ln(2)

        elif t == "list":
            set_font(10)
            for item in elem["items"]:
                if pdf.get_y() + 8 > pdf.h - pdf.b_margin:
                    pdf.add_page()
                    set_font(10)
                pdf.set_x(pdf.l_margin)  # 重置X位置
                pdf.multi_cell(0, 6, f"    • {clean_md(item)}")
            pdf.ln(2)

        elif t == "hr":
            y = pdf.get_y()
            pdf.line(pdf.l_margin, y, pdf.w - pdf.r_margin, y)
            pdf.ln(5)

        elif t == "image":
            img_path = resolve_image(elem["path"])
            if img_path:
                try:
                    # 计算图片宽度（不超过页面宽度）
                    max_w = pdf.w - pdf.l_margin - pdf.r_margin
                    from PIL import Image as PILImage

                    with PILImage.open(img_path) as im:
                        w, h = im.size
                    # 按比例缩放
                    img_w = min(max_w, 170)
                    img_h = img_w * h / w
                    # 检查是否需要换页
                    if pdf.get_y() + img_h > pdf.h - pdf.b_margin:
                        pdf.add_page()
                    pdf.image(img_path, x=pdf.l_margin, w=img_w)
                    pdf.ln(3)
                except Exception as e:
                    set_font(8)
                    pdf.set_text_color(150, 150, 150)
                    pdf.multi_cell(0, 5, f"[图片: {elem['alt']}]")
                    pdf.set_text_color(0, 0, 0)
            else:
                set_font(8)
                pdf.set_text_color(150, 150, 150)
                pdf.multi_cell(0, 5, f"[图片未找到: {elem['alt']}]")
                pdf.set_text_color(0, 0, 0)

        elif t == "table":
            rows = elem["rows"]
            if not rows:
                continue
            n_cols = max(len(r) for r in rows)
            usable_w = pdf.w - pdf.l_margin - pdf.r_margin
            row_h = 7

            # 计算列宽：按内容长度分配
            col_max_len = [0] * n_cols
            for row in rows:
                for j, cell in enumerate(row):
                    if j < n_cols:
                        col_max_len[j] = max(col_max_len[j], len(clean_md(cell)))
            total_len = max(sum(col_max_len), 1)
            col_w = [max(usable_w * (l / total_len), 20) for l in col_max_len]
            # 归一化到 usable_w
            scale = usable_w / sum(col_w)
            col_w = [w * scale for w in col_w]

            # 预估表格总高度，不够就换页（表格 < 整页高度时才整体移）
            table_h = len(rows) * row_h + 5
            remaining = pdf.h - pdf.b_margin - pdf.get_y()
            if table_h < (pdf.h - pdf.t_margin - pdf.b_margin) and table_h > remaining:
                pdf.add_page()

            for row_idx, row in enumerate(rows):
                # 检查单行是否需要换页
                if pdf.get_y() + row_h > pdf.h - pdf.b_margin:
                    pdf.add_page()

                if row_idx == 0:
                    set_font(9, bold=True)
                    pdf.set_fill_color(240, 240, 240)
                    fill = True
                else:
                    set_font(9)
                    fill = False

                x_start = pdf.l_margin
                y_start = pdf.get_y()
                for j, cell in enumerate(row):
                    if j >= n_cols:
                        break
                    pdf.set_xy(x_start + sum(col_w[:j]), y_start)
                    text = clean_md(cell)
                    # 按列宽截断（每个字约占 2.5pt 宽度）
                    max_chars = max(int(col_w[j] / 2.5), 5)
                    if len(text) > max_chars:
                        text = text[: max_chars - 1] + "…"
                    pdf.cell(col_w[j], row_h, text, border=1, align="L", fill=fill)
                pdf.ln(row_h)

            pdf.ln(3)

    try:
        pdf.output(output_path)
        return True
    except Exception as e:
        print(f"❌ PDF 生成失败: {e}", file=sys.stderr)
        return False


# ═══════════════════════════════════════════════
# 导出 DOCX（python-docx）
# ═══════════════════════════════════════════════


def export_docx(elements, output_path, md_dir):
    """将解析后的元素导出为 DOCX"""
    try:
        from docx import Document
        from docx.enum.text import WD_ALIGN_PARAGRAPH
        from docx.shared import Inches, Pt, RGBColor
    except ImportError:
        print(
            "❌ python-docx 未安装。请运行: pip3 install python-docx", file=sys.stderr
        )
        return False

    doc = Document()

    # 设置默认字体
    style = doc.styles["Normal"]
    style.font.size = Pt(10)

    def clean_md(text):
        """去除 Markdown 格式标记"""
        text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
        text = re.sub(r"\*(.+?)\*", r"\1", text)
        text = re.sub(r"`(.+?)`", r"\1", text)
        text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
        return text

    def add_formatted_paragraph(text, style_name="Normal"):
        """添加段落，处理 **加粗** 格式和换行"""
        p = doc.add_paragraph(style=style_name)
        text_lines = text.split("\n")
        for li, line_text in enumerate(text_lines):
            parts = re.split(r"(\*\*[^*]+\*\*)", line_text)
            for part in parts:
                if part.startswith("**") and part.endswith("**"):
                    run = p.add_run(part[2:-2])
                    run.bold = True
                else:
                    cleaned = re.sub(r"`(.+?)`", r"\1", part)
                    cleaned = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", cleaned)
                    p.add_run(cleaned)
            # 行间加换行（最后一行不加）
            if li < len(text_lines) - 1:
                p.add_run().add_break()
        return p

    def resolve_image(path):
        if os.path.isabs(path) and os.path.exists(path):
            return path
        rel = os.path.join(md_dir, path)
        if os.path.exists(rel):
            return rel
        return None

    for elem in elements:
        t = elem["type"]

        if t == "heading":
            level = min(elem["level"], 4)
            doc.add_heading(clean_md(elem["text"]), level=level)

        elif t == "paragraph":
            add_formatted_paragraph(elem["text"])

        elif t == "blockquote":
            p = doc.add_paragraph(clean_md(elem["text"]))
            p.paragraph_format.left_indent = Inches(0.3)
            for run in p.runs:
                run.font.color.rgb = RGBColor(100, 100, 100)
                run.font.size = Pt(9)

        elif t == "list":
            for item in elem["items"]:
                p = doc.add_paragraph(clean_md(item), style="List Bullet")

        elif t == "hr":
            p = doc.add_paragraph()
            p.paragraph_format.space_before = Pt(6)
            p.paragraph_format.space_after = Pt(6)
            run = p.add_run("─" * 60)
            run.font.color.rgb = RGBColor(180, 180, 180)
            run.font.size = Pt(8)

        elif t == "image":
            img_path = resolve_image(elem["path"])
            if img_path:
                try:
                    doc.add_picture(img_path, width=Inches(5.5))
                    # 图片居中
                    last_paragraph = doc.paragraphs[-1]
                    last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
                except Exception:
                    p = doc.add_paragraph(f"[图片: {elem['alt']}]")
                    p.runs[0].font.color.rgb = RGBColor(150, 150, 150)
            else:
                p = doc.add_paragraph(f"[图片未找到: {elem['alt']}]")
                p.runs[0].font.color.rgb = RGBColor(150, 150, 150)

        elif t == "table":
            rows = elem["rows"]
            if not rows:
                continue
            n_cols = max(len(r) for r in rows)
            table = doc.add_table(rows=len(rows), cols=n_cols)
            table.style = "Light Grid Accent 1"
            for i, row in enumerate(rows):
                for j, cell in enumerate(row):
                    if j < n_cols:
                        table.cell(i, j).text = clean_md(cell)
                        # 表头加粗
                        if i == 0:
                            for p in table.cell(i, j).paragraphs:
                                for run in p.runs:
                                    run.bold = True

    try:
        doc.save(output_path)
        return True
    except Exception as e:
        print(f"❌ DOCX 生成失败: {e}", file=sys.stderr)
        return False


# ═══════════════════════════════════════════════
# 主函数
# ═══════════════════════════════════════════════


def main():
    parser = argparse.ArgumentParser(
        description="将 Markdown 股票分析报告导出为 PDF 或 DOCX",
        epilog="示例：\n"
        "  python3 export_report.py report.md --format pdf\n"
        "  python3 export_report.py report.md --format docx\n"
        "  python3 export_report.py report.md --format both\n"
        "  python3 export_report.py report.md --format pdf --output ~/Downloads/报告.pdf\n",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("input", help="输入的 Markdown 文件路径")
    parser.add_argument(
        "--format",
        choices=["pdf", "docx", "both"],
        default="pdf",
        help="输出格式 (默认: pdf)",
    )
    parser.add_argument(
        "--output", default=None, help="输出文件路径（不指定则自动命名）"
    )
    args = parser.parse_args()

    # 读取 Markdown
    if not os.path.exists(args.input):
        print(f"❌ 文件不存在: {args.input}", file=sys.stderr)
        sys.exit(1)

    with open(args.input, "r", encoding="utf-8") as f:
        md_text = f.read()

    md_dir = os.path.dirname(os.path.abspath(args.input))
    base_name = os.path.splitext(os.path.basename(args.input))[0]

    # 解析
    print(f"📄 解析 Markdown: {args.input}", file=sys.stderr)
    elements = parse_markdown(md_text)
    n_img = sum(1 for e in elements if e["type"] == "image")
    n_tbl = sum(1 for e in elements if e["type"] == "table")
    print(f"   {len(elements)} 个元素，{n_img} 张图片，{n_tbl} 个表格", file=sys.stderr)

    # 导出
    formats = [args.format] if args.format != "both" else ["pdf", "docx"]

    for fmt in formats:
        if args.output and len(formats) == 1:
            out_path = args.output
        else:
            out_path = os.path.join(md_dir, f"{base_name}.{fmt}")

        print(f"📝 导出 {fmt.upper()}: {out_path}", file=sys.stderr)

        if fmt == "pdf":
            ok = export_pdf(elements, out_path, md_dir)
        else:
            ok = export_docx(elements, out_path, md_dir)

        if ok:
            size_kb = os.path.getsize(out_path) // 1024
            print(f"   ✅ 成功! {size_kb}KB", file=sys.stderr)
        else:
            print(f"   ❌ 失败", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/text_chart.py
content: #!/usr/bin/env python3
"""
文本折线图生成器 — 用于在 Webchat 等不支持图片的环境中展示股价走势
支持两种模式：
  1. Sparkline: ▁▂▃▅▇▅▃▂ （单行）
  2. Block chart: 多行 Unicode 块状图（更详细）
"""


def sparkline(data, min_val=None, max_val=None, width=40):
    """
    生成单行 sparkline 折线图。

    Args:
        data: 数值列表（如收盘价序列）
        min_val, max_val: 范围（自动计算为 None）
        width: 输出宽度（字符数）

    Returns:
        str: sparkline 字符串
    """
    if not data or len(data) < 2:
        return "▁" * (width or 20)

    n = len(data)
    if min_val is None:
        min_val = min(data)
    if max_val is None:
        max_val = max(data)

    span = max_val - min_val
    if span == 0:
        span = 1

    # 8 级 Unicode 块字符
    blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]

    result = []
    for i in range(n):
        idx = int((data[i] - min_val) / span * 7.999)
        result.append(blocks[max(0, min(7, idx))])

    return "".join(result)


def block_chart(
    data, labels=None, height=10, width=50, title="", show_min_max=True, color_up=True
):
    """
    生成多行 Unicode 块状折线图。

    Args:
        data: 数值列表
        labels: X 轴标签列表（日期等），与 data 等长
        height: 图表高度（行数）
        width: 数据点数量（会重采样）
        title: 标题
        show_min_max: 是否标注最高最低价
        color_up: 是否用颜色标记涨跌

    Returns:
        str: 多行图表字符串
    """
    if not data or len(data) < 2:
        return "(数据不足)"

    n = len(data)

    # 重采样到目标宽度
    if n > width:
        step = n / width
        sampled = []
        sampled_labels = []
        for i in range(width):
            idx = int(i * step)
            sampled.append(data[min(idx, n - 1)])
            if labels:
                sampled_labels.append(labels[min(idx, n - 1)])
        data = sampled
        if labels:
            labels = sampled_labels
    elif n < width:
        width = n

    min_val = min(data)
    max_val = max(data)
    span = max_val - min_val
    if span == 0:
        span = 1

    # 完整块字符集（8 级 + 上半/下半组合 = 25 级）
    # 这里用简化版：8 级
    full_blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇"]

    lines = []

    # 标题
    if title:
        lines.append(f"  {title}")
        lines.append("")

    # Y轴范围标注
    y_labels = []
    for i in range(height):
        val = (
            max_val - (i / (height - 1)) * span
            if height > 1
            else (max_val + min_val) / 2
        )
        y_labels.append(f"{val:.1f}")

    max_label_len = max(len(l) for l in y_labels) if y_labels else 0

    # 绘制每一行
    for row in range(height):
        threshold = max_val - ((row + 0.5) / height) * span

        line_parts = [f"{y_labels[row]:>{max_label_len}} │"]

        for val in data:
            if val >= threshold:
                line_parts.append("█")
            else:
                line_parts.append(" ")

        lines.append("".join(line_parts))

    # X轴
    x_axis = " " * (max_label_len + 2) + "─" * width
    lines.append(x_axis)

    # X轴标签（精简显示首尾+均匀采样）
    if labels and len(labels) > 0:
        label_positions = [0]
        if len(labels) > 2:
            label_positions.append(len(labels) - 1)
            step = max(1, (len(labels) - 1) // 4)
            for i in range(step, len(labels) - 1, step):
                label_positions.append(i)
        label_positions = sorted(set(label_positions))

        label_line = [" " * (max_label_len + 2)]
        pos_idx = 0
        for i in range(len(labels)):
            if pos_idx < len(label_positions) and i == label_positions[pos_idx]:
                lbl = str(labels[i])[-8:]  # 截短标签
                label_line.append(lbl[:width])
                pos_idx += 1
            else:
                label_line.append(" ")
        lines.append("".join(label_line))

    # 最高/最低价标注
    if show_min_max:
        max_idx = data.index(max(data))
        min_idx = data.index(min(data))
        lines.append("")
        info = f"  最高: {max_val:.1f} | 最低: {min_val:.1f} | 振幅: {span:.1f} ({span/min_val*100:.1f}%)"
        lines.append(info)

    return "\n".join(lines)


def candlestick_text(kline_data, height=12, width=40, title=""):
    """
    从 OHLCV 数据生成文本 K 线图。

    Args:
        kline_data: [{"open":o,"high":h,"low":l,"close":c,"volume":v}, ...]
        height: 图表高度
        width: 显示的K线根数
        title: 标题

    Returns:
        str: 文本K线图
    """
    if not kline_data or len(kline_data) < 2:
        return "(K线数据不足)"

    # 取最近的 width 根 K 线
    data = kline_data[-width:] if len(kline_data) > width else kline_data
    n = len(data)

    # 找全局高低点
    global_low = min(d.get("low", d.get("close", 0)) for d in data)
    global_high = max(d.get("high", d.get("close", 0)) for d in data)
    span = global_high - global_low
    if span == 0:
        span = 1

    lines = []
    if title:
        lines.append(f"  {title}")
        lines.append("")

    price_range = global_high - global_low
    for row in range(height):
        price = (
            global_high - (row / (height - 1)) * price_range
            if height > 1
            else (global_high + global_low) / 2
        )

        line = f"{price:>9.1f} │"

        for d in data:
            o = d.get("open", d.get("close", 0))
            h = d.get("high", d.get("close", 0))
            l = d.get("low", d.get("close", 0))
            c = d.get("close", 0)

            if h >= price >= l:
                # 这根K线的范围覆盖当前价格行
                if o <= price <= c:
                    line += "█"  # 实体（阳线）
                elif c <= price <= o:
                    line += "▓"  # 实体（阴线）
                elif price >= max(o, c):
                    line += "│"  # 上影线
                elif price <= min(o, c):
                    line += "│"  # 下影线
                else:
                    line += "█"
            else:
                line += " "

        lines.append(line)

    # 底部
    lines.append(" " * 11 + "─" * n)

    # 阳线/阴线标记
    marker_line = " " * 11
    for d in data:
        c = d.get("close", 0)
        o = d.get("open", c)
        if c >= o:
            marker_line += "▲"
        else:
            marker_line += "▼"
    lines.append(marker_line)

    # 最新价
    last = data[-1]
    lc = last.get("close", 0)
    lo = last.get("open", lc)
    change = lc - lo
    pct = change / lo * 100 if lo != 0 else 0
    sign = "+" if change >= 0 else ""
    lines.append(f"  最新: {lc:.1f} ({sign}{change:.1f}, {sign}{pct:.1f}%)")

    return "\n".join(lines)


# ── 测试 ──
if __name__ == "__main__":
    # 模拟腾讯股价数据（60天）
    prices = [
        550,
        558,
        562,
        555,
        560,
        568,
        572,
        578,
        582,
        590,
        595,
        602,
        615,
        622,
        618,
        610,
        598,
        585,
        570,
        555,
        545,
        535,
        520,
        505,
        495,
        480,
        485,
        495,
        510,
        525,
        530,
        528,
        518,
        508,
        500,
        495,
        490,
        488,
        492,
        498,
        505,
        512,
        518,
        515,
        508,
        500,
        492,
        485,
        480,
        478,
        482,
        488,
        492,
        490,
        485,
        480,
        478,
        482,
        485,
        483,
    ]

    dates = [f"{'01' if i<10 else '11'}/{(i%30)+1:02d}" for i in range(len(prices))]

    print("=" * 60)
    print("测试 1: Sparkline（单行）")
    print("=" * 60)
    s = sparkline(prices[-30:])
    print(f"  近30日: {s}")
    print()

    print("=" * 60)
    print("测试 2: Block Chart（多行块状图）")
    print("=" * 60)
    print(
        block_chart(
            prices[-30:],
            labels=dates[-30:],
            height=10,
            width=40,
            title="腾讯控股(0700.HK) 近30日收盘价",
        )
    )
    print()

    print("=" * 60)
    print("测试 3: Candlestick Text（文本K线）")
    print("=" * 60)
    # 模拟 OHLCV 数据
    ohlcv = []
    base = 480
    for i, p in enumerate(prices[-20:]):
        rng = abs(p - base) * 0.03
        ohlcv.append(
            {
                "open": p - rng * (0.5 if i % 3 == 0 else -0.3),
                "high": p + rng,
                "low": p - rng * 1.2,
                "close": p,
                "volume": 1000000 + i * 50000,
            }
        )
    print(candlestick_text(ohlcv, height=10, title="腾讯控股(0700.HK) 近20日K线"))


path: scripts/fetch_all.py
content: #!/usr/bin/env python3
"""
股票数据一键获取 + 图表生成 + 报告模板生成 v28.0

数据源三路冗余：akshare → 东方财富原始接口 → yfinance
输出：data.json + PNG 图表 + 数据摘要 JSON（供多模态主模型分析）

工作模式：
  脚本只负责数据采集和图表生成，不生成报告文本。
  主模型（如 glm-5v-turbo）读取 data.json 和图片后，自行输出分析报告。

用法: python3 fetch_all.py 0700.HK [--adr TCEHY]
"""

import argparse
import json
import os
import urllib.parse
import urllib.request
from datetime import datetime, timedelta

import numpy as np
import pandas as pd

# 文本图表（Webchat 内嵌展示用）
try:
    from text_chart import block_chart, sparkline

    HAS_TEXT_CHART = True
except ImportError:
    HAS_TEXT_CHART = False

# ── 可选依赖，缺失不中断 ──
try:
    import akshare as ak

    HAS_AKSHARE = True
except ImportError:
    HAS_AKSHARE = False
    print("  ⚠ akshare 未安装，跳过此数据源 (pip3 install akshare)")

try:
    import requests as _requests
    import yfinance as yf

    # 创建带 10 秒超时的 session，所有 yfinance 调用共用
    _yf_session = _requests.Session()
    _yf_session.headers["User-Agent"] = "Mozilla/5.0"
    _orig_request = _yf_session.request

    def _timeout_request(*args, **kwargs):
        kwargs.setdefault("timeout", 10)
        return _orig_request(*args, **kwargs)

    _yf_session.request = _timeout_request
    HAS_YFINANCE = True
    print(f"  ✅ yfinance（单次请求超时: 10秒）")
except ImportError:
    HAS_YFINANCE = False
    _yf_session = None
    print("  ⚠ yfinance 未安装，跳过此数据源")


def _yf(code):
    """创建带超时控制的 yfinance Ticker"""
    if _yf_session:
        return yf.Ticker(code, session=_yf_session)
    return yf.Ticker(code)


try:
    import tushare as ts

    _TS_TOKEN = os.environ.get("TUSHARE_TOKEN", "")
    if _TS_TOKEN:
        ts.set_token(_TS_TOKEN)
        TS_PRO = ts.pro_api()
        HAS_TUSHARE = True
        print(f"  ✅ tushare token 已配置")
    else:
        HAS_TUSHARE = False
        print(
            "  ⚠ TUSHARE_TOKEN 未设置，跳过 tushare（在环境变量或 OpenClaw 配置中设置）"
        )
except ImportError:
    HAS_TUSHARE = False
    TS_PRO = None
    print("  ⚠ tushare 未安装，跳过此数据源 (pip3 install tushare)")

import matplotlib

matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties


# ── 中文字体 ──
def _find_cn_font():
    for p in [
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/Supplemental/PingFang.ttc",
        "/System/Library/Fonts/STHeiti Light.ttc",
        "/System/Library/Fonts/STHeiti Medium.ttc",
        "/Library/Fonts/Arial Unicode MS.ttf",
        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
        "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
        "C:\\Windows\\Fonts\\msyh.ttc",
        "C:\\Windows\\Fonts\\simhei.ttf",
    ]:
        if os.path.exists(p):
            return p
    return None


_FONT_PATH = _find_cn_font()
if _FONT_PATH:
    matplotlib.rcParams["font.family"] = FontProperties(fname=_FONT_PATH).get_name()
matplotlib.rcParams["axes.unicode_minus"] = False

# ── 颜色主题 ──
C = {
    "bg": "#0f1117",
    "surface": "#1a1d2e",
    "pos": "#ef5350",
    "neg": "#26a69a",
    "text": "#e0e0e0",
    "grid": "#2a2d3e",
    "accent": "#5c6bc0",
    "orange": "#ff9800",
}


def dark_style():
    plt.rcParams.update(
        {
            "figure.facecolor": C["bg"],
            "axes.facecolor": C["surface"],
            "axes.edgecolor": C["grid"],
            "axes.labelcolor": C["text"],
            "text.color": C["text"],
            "xtick.color": C["text"],
            "ytick.color": C["text"],
            "grid.color": C["grid"],
            "grid.linestyle": ":",
            "grid.alpha": 0.5,
            "font.size": 9,
            "axes.unicode_minus": False,
        }
    )


def save_fig(fig, path):
    fig.savefig(path, dpi=150, bbox_inches="tight", facecolor=fig.get_facecolor())
    plt.close(fig)


# ═══════════════════════════════════════════════
# 内置技术指标
# ═══════════════════════════════════════════════


def calc_ema(s, n):
    return s.ewm(span=n, adjust=False).mean()


def calc_macd(close):
    m = calc_ema(close, 12) - calc_ema(close, 26)
    s = calc_ema(m, 9)
    return m, s, m - s


def calc_rsi(close, n=14):
    d = close.diff()
    g = d.where(d > 0, 0.0).ewm(alpha=1 / n, min_periods=n).mean()
    l = (-d).where(d < 0, 0.0).ewm(alpha=1 / n, min_periods=n).mean()
    return 100 - 100 / (1 + g / l)


# ═══════════════════════════════════════════════
# 数据获取：三路冗余
# ═══════════════════════════════════════════════


def _http_json(url, timeout=15):
    try:
        req = urllib.request.Request(
            url,
            headers={
                "User-Agent": "Mozilla/5.0",
                "Referer": "https://data.eastmoney.com/",
            },
        )
        return json.loads(
            urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8")
        )
    except Exception:
        return None


def _detect_market(code):
    """返回 (market, pure_code)。A股优先原则：不确定时默认A股。"""
    c = code.upper().strip()

    # 1. 带后缀（最高优先，无歧义）
    if ".HK" in c:
        return "hk", c.split(".")[0].zfill(5)
    if ".SS" in c or ".SH" in c:
        return "sh", c.split(".")[0]
    if ".SZ" in c:
        return "sz", c.split(".")[0]

    # 2. 纯字母 → 美股
    if c.isalpha():
        return "us", code

    # 3. 恰好6位数字 → A股
    if c.isdigit() and len(c) == 6:
        if c[0] in ("6", "9"):
            return "sh", c
        return "sz", c

    # 4. <6位数字 → 补零看是否像A股，不像则判港股
    if c.isdigit() and len(c) < 6:
        padded = c.zfill(6)
        a_share_prefixes = (
            "000",
            "001",
            "002",
            "003",
            "300",
            "600",
            "601",
            "603",
            "605",
            "688",
        )
        if padded[:3] in a_share_prefixes:
            if padded[0] in ("6", "9"):
                return "sh", padded
            return "sz", padded
        else:
            return "hk", c.zfill(5)

    # 5. 兜底 → A股优先
    return "sz", code


# ── K线 ──


def kline_akshare(code, market, pure):
    if not HAS_AKSHARE:
        return None
    try:
        if market == "hk":
            df = ak.stock_hk_hist(
                symbol=pure,
                period="daily",
                start_date=(datetime.now() - timedelta(days=180)).strftime("%Y%m%d"),
                end_date=datetime.now().strftime("%Y%m%d"),
                adjust="qfq",
            )
        elif market in ("sh", "sz"):
            df = ak.stock_zh_a_hist(
                symbol=pure,
                period="daily",
                start_date=(datetime.now() - timedelta(days=180)).strftime("%Y%m%d"),
                end_date=datetime.now().strftime("%Y%m%d"),
                adjust="qfq",
            )
        else:
            return None
        if df is None or df.empty:
            return None
        # 标准化列名
        col_map = {
            "日期": "Date",
            "开盘": "Open",
            "收盘": "Close",
            "最高": "High",
            "最低": "Low",
            "成交量": "Volume",
        }
        df = df.rename(columns=col_map)
        if "Date" in df.columns:
            df["Date"] = pd.to_datetime(df["Date"])
            df = df.set_index("Date")
        for c in ["Open", "Close", "High", "Low", "Volume"]:
            if c in df.columns:
                df[c] = pd.to_numeric(df[c], errors="coerce")
        print(f"    akshare: {len(df)} 条K线 ✅")
        return df
    except Exception as e:
        print(f"    akshare K线失败: {e}")
        return None


def kline_eastmoney(code, market, pure):
    try:
        sid = {"hk": f"116.{pure}", "sh": f"1.{pure}", "sz": f"0.{pure}"}.get(market)
        if not sid:
            return None
        end = datetime.now().strftime("%Y%m%d")
        beg = (datetime.now() - timedelta(days=180)).strftime("%Y%m%d")
        url = (
            f"http://push2his.eastmoney.com/api/qt/stock/kline/get?"
            f"secid={sid}&fields1=f1,f2,f3,f4,f5,f6"
            f"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"
            f"&klt=101&fqt=1&beg={beg}&end={end}&lmt=120"
            f"&ut=fa5fd1943c7b386f172d6893dbfba10b"
        )
        data = _http_json(url)
        if not data or not data.get("data") or not data["data"].get("klines"):
            return None
        rows = []
        for line in data["data"]["klines"]:
            p = line.split(",")
            if len(p) >= 7:
                rows.append(
                    {
                        "Date": pd.Timestamp(p[0]),
                        "Open": float(p[1]),
                        "Close": float(p[2]),
                        "High": float(p[3]),
                        "Low": float(p[4]),
                        "Volume": int(float(p[5])),
                    }
                )
        if not rows:
            return None
        df = pd.DataFrame(rows).set_index("Date")
        print(f"    东方财富: {len(df)} 条K线 ✅")
        return df
    except Exception as e:
        print(f"    东方财富K线失败: {e}")
        return None


def kline_yfinance(code):
    if not HAS_YFINANCE:
        return None
    try:
        df = _yf(code).history(period="6mo", interval="1d")
        if df is not None and not df.empty and len(df) > 5:
            print(f"    yfinance: {len(df)} 条K线 ✅")
            return df
        return None
    except Exception as e:
        print(f"    yfinance K线失败: {e}")
        return None


def _ts_code(market, pure):
    """转换为 Tushare ts_code 格式"""
    if market == "hk":
        return f"{pure}.HK"  # 00700.HK
    if market == "sh":
        return f"{pure}.SH"  # 600519.SH
    if market == "sz":
        return f"{pure}.SZ"  # 000001.SZ
    return None


def kline_tushare(code, market, pure):
    if not HAS_TUSHARE:
        return None
    try:
        tc = _ts_code(market, pure)
        if not tc:
            return None
        end = datetime.now().strftime("%Y%m%d")
        beg = (datetime.now() - timedelta(days=180)).strftime("%Y%m%d")
        if market == "hk":
            df = TS_PRO.hk_daily(ts_code=tc, start_date=beg, end_date=end)
        else:
            df = TS_PRO.daily(ts_code=tc, start_date=beg, end_date=end)
        if df is None or df.empty:
            return None
        # 标准化列名
        col_map = {
            "trade_date": "Date",
            "open": "Open",
            "close": "Close",
            "high": "High",
            "low": "Low",
            "vol": "Volume",
        }
        df = df.rename(columns=col_map)
        if "Date" in df.columns:
            df["Date"] = pd.to_datetime(df["Date"])
            df = df.set_index("Date").sort_index()
        for c in ["Open", "Close", "High", "Low"]:
            if c in df.columns:
                df[c] = pd.to_numeric(df[c], errors="coerce")
        if "Volume" in df.columns:
            df["Volume"] = (
                pd.to_numeric(df["Volume"], errors="coerce").fillna(0).astype(int)
            )
        print(f"    tushare: {len(df)} 条K线 ✅")
        return df
    except Exception as e:
        print(f"    tushare K线失败: {e}")
        return None


# fetch_kline 已移除——main() 直接并行调用 kline_tushare/akshare/eastmoney/yfinance


def _em_nid(market, pure):
    """市场+代码 → 东方财富 nid"""
    prefix = {"hk": "116", "sh": "1", "sz": "0", "us": "105"}.get(market)
    return f"{prefix}.{pure}" if prefix else None


_EM_PIC_URL = "http://webquotepic.eastmoney.com/GetPic.aspx"
_EM_PIC_HEADERS = {
    "Referer": "https://quote.eastmoney.com/",
    "User-Agent": "Mozilla/5.0",
}


def _download_em_pic(nid, image_type, save_path, timeout=12, retries=3):
    """从东方财富图片服务器下载一张图，返回是否成功"""
    import time as _time

    url = f"{_EM_PIC_URL}?nid={nid}&imageType={image_type}"
    for attempt in range(retries):
        try:
            req = urllib.request.Request(url, headers=_EM_PIC_HEADERS)
            data = urllib.request.urlopen(req, timeout=timeout).read()
            if len(data) < 1500:  # 小于1500字节通常是"暂无数据"占位图
                return False
            os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
            with open(save_path, "wb") as f:
                f.write(data)
            return True
        except Exception as e:
            if attempt < retries - 1:
                _time.sleep(1)
            else:
                print(f"    ⚠ 东方财富图片下载失败 ({image_type}): {e}")
    return False


def _page_chart_score(page, page_idx, total_pages):
    """评估 PDF 页面是否是图表页。返回分数，>3 才值得提取。
    核心逻辑：必须有嵌入图片才是图表页，纯文字/纯表格不要。"""
    imgs = page.get_images(full=True)
    draws = page.get_drawings()
    txt_len = len(page.get_text().strip())

    # 基础分：图片权重最高（嵌入的图/图表截图）
    score = len(imgs) * 4

    # 图形分（draws 包含图表线条，但也包含表格边框，权重给低）
    score += min(len(draws) / 20, 3)

    # 文字量：适量加分（图注/标题），过多扣分（纯文字页）
    if 30 < txt_len < 300:
        score += 2
    elif txt_len > 1000:
        score -= 3
    if txt_len > 2000:
        score -= 5

    # 没有嵌入图片的页面，得分封顶（表格页、目录页、纯文字页）
    if len(imgs) == 0:
        score = min(score, 2)

    # 首尾页扣分（封面、免责声明）
    if page_idx == 0:
        score -= 2
    if page_idx >= total_pages - 2:
        score -= 2

    return score


def fetch_ir_presentation(code, pure, market, out_dir):
    """尝试搜索公司 IR 演示 PDF 并提取关键图表页"""
    try:
        import fitz
    except ImportError:
        print("    ⚠ pymupdf 未安装，跳过 IR 演示提取")
        return []

    ir_dir = os.path.join(out_dir, "ir_presentation")
    os.makedirs(ir_dir, exist_ok=True)

    # 对港股用东方财富的公告接口搜索业绩演示
    if market == "hk":
        try:
            kw = urllib.parse.quote("业绩演示+业绩说明会+业绩发布")
            url = (
                f"http://np-anotice-stock.eastmoney.com/api/security/ann?"
                f"cb=&page_size=10&page_index=1&ann_type=A&client_source=web"
                f"&f_node=1&s_node=1&stock_list={pure}"
                f"&sr=-1&columns=TITLE&keyword={kw}"
            )
            data = _http_json(url)
            # 尝试从返回数据中提取 PDF URL
            if data and data.get("data") and data["data"].get("list"):
                for ann in data["data"]["list"][:3]:
                    art_code = ann.get("art_code", "")
                    title = ann.get("title", "")
                    if any(
                        kw in title
                        for kw in ["演示", "业绩", "说明会", "发布会", "交流"]
                    ):
                        pdf_url = f"https://pdf.dfcfw.com/pdf/H2_{art_code}_1.pdf"
                        pdf_path = os.path.join(ir_dir, "ir_presentation.pdf")
                        print(f"    尝试下载 IR 演示: {title[:40]}...")
                        req = urllib.request.Request(
                            pdf_url, headers={"User-Agent": "Mozilla/5.0"}
                        )
                        resp = urllib.request.urlopen(req, timeout=30)
                        with open(pdf_path, "wb") as f:
                            f.write(resp.read())
                        if os.path.getsize(pdf_path) < 500:
                            continue
                        # 提取关键图表页
                        doc = fitz.open(pdf_path)
                        pages = []
                        for i in range(min(len(doc), 40)):
                            page = doc[i]
                            score = _page_chart_score(page, i, len(doc))
                            if score > 3:
                                mat = fitz.Matrix(200 / 72, 200 / 72)
                                pix = page.get_pixmap(matrix=mat)
                                png = os.path.join(ir_dir, f"ir_p{i+1:03d}.png")
                                pix.save(png)
                                pages.append(png)
                                if len(pages) >= 6:
                                    break
                        doc.close()
                        if pages:
                            print(f"    ✅ IR 演示提取了 {len(pages)} 页图表")
                            return pages
        except Exception as e:
            print(f"    IR 演示搜索失败: {e}")

    return []


# ── 基本面 ──


def _fetch_info_eastmoney(market, pure):
    """东方财富 push2 实时行情（独立，不依赖其他源）"""
    if not market or not pure or market not in ("hk", "sh", "sz"):
        return {}
    try:
        secid = {"hk": f"116.{pure}", "sh": f"1.{pure}", "sz": f"0.{pure}"}[market]
        url = (
            f"http://push2.eastmoney.com/api/qt/stock/get?"
            f"fltt=2&invt=2&secid={secid}"
            f"&fields=f43,f44,f45,f46,f57,f58,f60,f116,f117,f162,f167,f168,f169,f170"
        )
        data = _http_json(url)
        if data and data.get("data"):
            d = data["data"]
            r = {
                "currentPrice": d.get("f43", 0),
                "previousClose": d.get("f60", 0),
                "trailingPE": d.get("f162", 0),
                "priceToBook": d.get("f167", 0),
                "marketCap": d.get("f116", 0),
                "fiftyTwoWeekHigh": d.get("f44", 0),
                "fiftyTwoWeekLow": d.get("f45", 0),
                "shortName": d.get("f58", ""),
            }
            print(f"    东方财富实时行情 ✅")
            return r
    except Exception as e:
        print(f"    东方财富实时行情失败: {e}")
    return {}


def _fetch_info_yfinance(code):
    """yfinance 基本面（独立，不依赖其他源）"""
    if not HAS_YFINANCE:
        return {}
    try:
        info = _yf(code).info or {}
        keys = [
            "currentPrice",
            "previousClose",
            "marketCap",
            "trailingPE",
            "forwardPE",
            "priceToBook",
            "dividendYield",
            "returnOnEquity",
            "revenueGrowth",
            "earningsGrowth",
            "freeCashflow",
            "totalRevenue",
            "fiftyTwoWeekHigh",
            "fiftyTwoWeekLow",
            "shortName",
            "industry",
            "sector",
        ]
        return {k: info[k] for k in keys if info.get(k) is not None}
    except Exception as e:
        print(f"    yfinance 基本面失败: {e}")
        return {}


def _fetch_info_tushare(market, pure):
    """tushare 基本面（独立，不依赖其他源）"""
    if not HAS_TUSHARE or not market or not pure:
        return {}
    try:
        tc = _ts_code(market, pure)
        if not tc:
            return {}
        if market == "hk":
            db = TS_PRO.hk_basic(ts_code=tc, fields="ts_code,pe,pb,total_mv,float_mv")
        else:
            db = TS_PRO.daily_basic(
                ts_code=tc,
                fields="ts_code,trade_date,pe_ttm,pb,turnover_rate,total_mv,circ_mv",
            )
        if db is None or db.empty:
            return {}
        row = db.iloc[0]
        r = {}
        if "pe_ttm" in row and pd.notna(row["pe_ttm"]):
            r["trailingPE"] = round(float(row["pe_ttm"]), 2)
        if "pe" in row and pd.notna(row.get("pe")):
            r["trailingPE"] = round(float(row["pe"]), 2)
        if "pb" in row and pd.notna(row["pb"]):
            r["priceToBook"] = round(float(row["pb"]), 2)
        if "total_mv" in row and pd.notna(row["total_mv"]):
            r["marketCap"] = round(float(row["total_mv"]) * 10000, 0)
        print(f"    tushare 基本面 ✅")
        return r
    except Exception as e:
        print(f"    tushare 基本面失败: {e}")
        return {}


def _merge_info(em_info, yf_info, ts_info):
    """合并三个源的基本面数据，优先级：东方财富 > yfinance > tushare"""
    result = {}
    # tushare 最低优先级先写入
    result.update({k: v for k, v in ts_info.items() if v})
    # yfinance 覆盖
    result.update({k: v for k, v in yf_info.items() if v})
    # 东方财富最高优先级覆盖
    result.update({k: v for k, v in em_info.items() if v})
    return result


def fetch_info(code, market=None, pure=None):
    """基本面数据（串行版，保留兼容性）"""
    em = _fetch_info_eastmoney(market, pure)
    yf_i = _fetch_info_yfinance(code)
    ts = _fetch_info_tushare(market, pure)
    return _merge_info(em, yf_i, ts)


# ── 宏观 ──


def fetch_macro():
    """宏观指标——内部并行获取，单个 ticker 5秒超时"""
    from concurrent.futures import ThreadPoolExecutor, as_completed

    tickers = {
        "VIX": "^VIX",
        "美债10Y": "^TNX",
        "标普500": "^GSPC",
        "纳斯达克": "^IXIC",
        "USD/HKD": "USDHKD=X",
        "USD/CNH": "USDCNH=X",
        "上证指数": "000001.SS",
        "沪深300": "000300.SS",
        "恒生指数": "^HSI",
        "恒生科技": "^HSTECH",
    }
    if not HAS_YFINANCE:
        return {}

    def _get_one(name, code):
        try:
            h = _yf(code).history(period="5d", interval="1d")
            if not h.empty:
                val = float(h["Close"].iloc[-1])
                prev = float(h["Close"].iloc[-2]) if len(h) > 1 else val
                return name, {
                    "value": round(val, 2),
                    "change_pct": round((val - prev) / prev * 100, 2) if prev else 0,
                }
        except Exception:
            pass
        return name, None

    result = {}
    with ThreadPoolExecutor(max_workers=5) as pool:
        futures = {pool.submit(_get_one, n, c): n for n, c in tickers.items()}
        for future in as_completed(futures, timeout=15):
            try:
                name, val = future.result(timeout=5)
                if val:
                    result[name] = val
            except Exception:
                pass
    print(f"    宏观: 获取了 {len(result)}/{len(tickers)} 个指标")
    return result


# ── 资金流 ──


def fetch_capital_flow(code, market, pure, days=20):
    label = {"hk": "南向资金", "sh": "北向资金", "sz": "北向资金"}.get(market, "N/A")
    if market not in ("hk", "sh", "sz"):
        return [], label
    try:
        mkt = {"hk": "003", "sh": "001", "sz": "002"}[market]
        url = (
            f"https://datacenter-web.eastmoney.com/api/data/v1/get?"
            f"reportName=RPT_MUTUAL_HOLDSTOCKNORTH_STA&columns=ALL"
            f"&filter=(SECURITY_CODE=%22{pure}%22)"
            f"&pageNumber=1&pageSize={days}&sortTypes=-1&sortColumns=TRADE_DATE"
        )
        data = _http_json(url)
        if not data or not data.get("result") or not data["result"].get("data"):
            return [], label
        out = [
            {
                "date": (r.get("TRADE_DATE") or "")[:10],
                "shares_change": r.get("SHARES_CHANGE", 0),
                "hold_shares": r.get("HOLD_SHARES", 0),
            }
            for r in data["result"]["data"]
        ]
        out.sort(key=lambda x: x["date"])
        return out, label
    except Exception:
        return [], label


# ── 财联社新闻 ──


def fetch_cls_news(stock_name="", count=20):
    """从财联社获取最新快讯，按股票名过滤"""
    try:
        url = (
            f"https://www.cls.cn/nodeapi/updateTelegraphList?"
            f"app=CailianpressWeb&os=web&sv=7.7.5&rn={count}"
        )
        data = _http_json(url)
        if not data or not data.get("data"):
            return []
        results = []
        for item in data["data"].get("roll_data") or []:
            content = item.get("content", "")
            title = item.get("title", "") or content[:60]
            ctime = item.get("ctime", 0)
            date = ""
            if ctime:
                try:
                    from datetime import datetime as dt2

                    date = dt2.fromtimestamp(ctime).strftime("%Y-%m-%d %H:%M")
                except Exception:
                    pass
            results.append(
                {
                    "title": title,
                    "content": content[:200],
                    "date": date,
                    "source": "财联社",
                }
            )
        # 如果有股票名，过滤相关新闻
        if stock_name:
            filtered = [
                r
                for r in results
                if stock_name in r["content"] or stock_name in r["title"]
            ]
            if filtered:
                print(f"    财联社: {len(filtered)} 条相关新闻（共 {len(results)} 条）")
                return filtered
        # 过滤不到相关新闻则返回空（不要返回全市场无关快讯）
        print(f"    ⚠ 财联社: 无'{stock_name}'相关新闻（{len(results)}条快讯均不匹配）")
        return []
    except Exception as e:
        print(f"    财联社新闻失败: {e}")
        return []


# ── 主力资金流 ──


def fetch_main_capital_flow(code, market, pure):
    """从东方财富获取个股主力资金流向（区别于互联互通资金）"""
    if not market or not pure:
        return {}
    try:
        secid = {"hk": f"116.{pure}", "sh": f"1.{pure}", "sz": f"0.{pure}"}.get(market)
        if not secid:
            return {}
        url = (
            f"http://push2.eastmoney.com/api/qt/stock/fflow/daykline/get?"
            f"secid={secid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65"
            f"&lmt=10"
        )
        data = _http_json(url)
        if not data or not data.get("data"):
            return {}
        klines = data["data"].get("klines", [])
        if not klines:
            return {}
        # 解析最近几天的主力资金
        days = []
        for line in klines[-5:]:
            parts = line.split(",")
            if len(parts) >= 7:
                days.append(
                    {
                        "date": parts[0],
                        "main_net_inflow": (
                            float(parts[1]) if parts[1] else 0
                        ),  # 主力净流入
                        "retail_net_inflow": (
                            float(parts[5]) if parts[5] else 0
                        ),  # 散户净流入
                    }
                )
        if days:
            # 汇总
            total_main = sum(d["main_net_inflow"] for d in days)
            consecutive = 0
            direction = 1 if days[-1]["main_net_inflow"] > 0 else -1
            for d in reversed(days):
                if (d["main_net_inflow"] > 0 and direction > 0) or (
                    d["main_net_inflow"] < 0 and direction < 0
                ):
                    consecutive += 1
                else:
                    break
            result = {
                "recent_days": days,
                "total_main_net_inflow": round(total_main, 0),
                "direction": "主力净流入" if direction > 0 else "主力净流出",
                "consecutive_days": consecutive,
            }
            print(f"    主力资金: {result['direction']} 连续{consecutive}天")
            return result
        return {}
    except Exception as e:
        print(f"    主力资金失败: {e}")
        return {}


def fetch_reports(pure):
    try:
        today = datetime.now().strftime("%Y-%m-%d")
        begin = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
        url = (
            f"https://reportapi.eastmoney.com/report/list?"
            f"industryCode=*&pageSize=15&industry=*&rating=*&ratingChange=*"
            f"&beginTime={begin}&endTime={today}&pageNo=1&fields=&qType=0&orgCode=&rcode=&stockCodes={pure}"
        )
        data = _http_json(url)
        if not data:
            return []
        results = []
        for r in data.get("data") or []:
            item = {
                "org": r.get("orgSName", ""),
                "rating": r.get("emRatingName", ""),
                "change": r.get("ratingChange", ""),
                "title": r.get("title", "")[:60],
                "date": (r.get("publishDate") or "")[:10],
                "infoCode": r.get("infoCode", ""),
            }
            if item["infoCode"]:
                item["pdf_url"] = (
                    f"https://pdf.dfcfw.com/pdf/H3_{item['infoCode']}_1.pdf"
                )
            results.append(item)
        return results
    except Exception:
        return []


# ── 研报 PDF 自动下载 ──


def auto_fetch_report_pdf(
    reports, out_dir, stock_name="", stock_code="", max_reports=2, max_pages=4
):
    """下载研报 PDF 并提取图表页。stock_name/stock_code 用于过滤无关研报。"""
    all_pages = []
    if not reports:
        return all_pages
    try:
        import fitz
    except ImportError:
        print("    ⚠ pymupdf 未安装，跳过研报PDF提取")
        return all_pages

    rdir = os.path.join(out_dir, "research_reports")
    os.makedirs(rdir, exist_ok=True)
    downloaded = 0
    for r in reports:
        if downloaded >= max_reports:
            break
        if len(all_pages) >= max_pages:
            break
        pdf_url = r.get("pdf_url")
        if not pdf_url:
            continue

        # 标题过滤：跳过不含目标股票名/代码的研报
        title = r.get("title", "")
        if stock_name or stock_code:
            match = False
            if stock_name and stock_name in title:
                match = True
            if stock_code and stock_code in title:
                match = True
            if not match:
                print(f"    ⏭ 跳过无关研报: {title[:40]}")
                continue

        pdf_path = os.path.join(rdir, f"report_{downloaded+1}.pdf")
        print(f"    尝试下载研报: [{r['org']}] {title[:40]}...")
        try:
            req = urllib.request.Request(
                pdf_url,
                headers={
                    "User-Agent": "Mozilla/5.0",
                    "Referer": "https://data.eastmoney.com/",
                },
            )
            resp = urllib.request.urlopen(req, timeout=20)
            with open(pdf_path, "wb") as f:
                f.write(resp.read())
            if os.path.getsize(pdf_path) < 100:
                continue
            doc = fitz.open(pdf_path)
            for i in range(min(len(doc), 30)):
                if len(all_pages) >= max_pages:
                    break
                page = doc[i]
                score = _page_chart_score(page, i, len(doc))
                if score > 3:
                    mat = fitz.Matrix(200 / 72, 200 / 72)
                    pix = page.get_pixmap(matrix=mat)
                    png = os.path.join(rdir, f"report_p{i+1:03d}.png")
                    pix.save(png)
                    all_pages.append(png)
            doc.close()
            downloaded += 1
            if all_pages:
                print(f"    ✅ 提取了 {len(all_pages)} 页研报图表")
        except Exception as e:
            print(f"    ⚠ 研报下载失败: {e}")
    return all_pages


# ═══════════════════════════════════════════════
# 技术指标计算
# ═══════════════════════════════════════════════


def compute_technicals(df):
    if df is None or len(df) < 20:
        return {}
    try:
        latest = df.iloc[-1]
        ma5 = df["Close"].rolling(5).mean().iloc[-1]
        ma20 = df["Close"].rolling(20).mean().iloc[-1]
        ma60 = df["Close"].rolling(60).mean().iloc[-1] if len(df) >= 60 else None
        _, _, hist = calc_macd(df["Close"])
        mh = float(hist.iloc[-1]) if not hist.empty else None
        ph = float(hist.iloc[-2]) if len(hist) > 1 else None
        rsi = calc_rsi(df["Close"])
        rv = float(rsi.iloc[-1]) if rsi is not None and not rsi.empty else None
        vm = df["Volume"].rolling(20).mean().iloc[-1]
        vr = float(latest["Volume"] / vm) if vm > 0 else 1.0
        r = {
            "close": round(float(latest["Close"]), 2),
            "ma5": round(float(ma5), 2),
            "ma20": round(float(ma20), 2),
            "vol_ratio": round(vr, 2),
        }
        if rv and not np.isnan(rv):
            r["rsi"] = round(rv, 1)
        if mh and not np.isnan(mh):
            r["macd_hist"] = round(mh, 4)
        if ph and not np.isnan(ph):
            r["prev_macd_hist"] = round(ph, 4)
        if ma60:
            r["ma60"] = round(float(ma60), 2)
        if ma60:
            if ma5 > ma20 > ma60:
                r["ma_status"] = "多头排列"
            elif ma5 < ma20 < ma60:
                r["ma_status"] = "空头排列"
            else:
                r["ma_status"] = "交叉缠绕"
        if mh is not None and ph is not None:
            if ph < 0 and mh > 0:
                r["macd_status"] = "金叉"
            elif ph > 0 and mh < 0:
                r["macd_status"] = "死叉"
            elif mh > ph:
                r["macd_status"] = "动能增强"
            else:
                r["macd_status"] = "动能减弱"
        if rv and not np.isnan(rv):
            r["rsi_status"] = "超买" if rv > 70 else ("超卖" if rv < 30 else "中性")
        return r
    except Exception:
        return {}


# ═══════════════════════════════════════════════
# 图表生成
# ═══════════════════════════════════════════════


def chart_kline(df, code, out):
    """本地绘制 K 线图（fallback，仅在东方财富直链图片不可用时调用）"""
    if df is None or df.empty:
        return None
    try:
        import mplfinance as mpf

        mc = mpf.make_marketcolors(
            up=C["pos"],
            down=C["neg"],
            edge="inherit",
            wick="inherit",
            volume={"up": C["pos"], "down": C["neg"]},
        )
        style = mpf.make_mpf_style(
            marketcolors=mc,
            gridstyle=":",
            y_on_right=True,
            facecolor=C["surface"],
            figcolor=C["bg"],
            gridcolor=C["grid"],
            rc={
                "axes.labelcolor": C["text"],
                "xtick.color": C["text"],
                "ytick.color": C["text"],
                "text.color": C["text"],
                "font.size": 9,
            },
        )
        path = os.path.join(out, "kline.png")
        mav = (5, 20, 60) if len(df) >= 60 else (5, 20)
        mpf.plot(
            df,
            type="candle",
            style=style,
            volume=True,
            mav=mav,
            title=f"\n{code} 日K线",
            figsize=(14, 7),
            savefig=dict(fname=path, dpi=150, bbox_inches="tight"),
        )
        print(f"    ✅ kline: 本地绘制")
        return path
    except Exception as e:
        print(f"    ⚠ K线图失败: {e}")
        return None


def chart_capital_flow(data, code, out, label="资金流"):
    if not data or len(data) < 3:
        return None
    try:
        dark_style()
        df = pd.DataFrame(data)
        df["date"] = pd.to_datetime(df["date"])
        ch = df["shares_change"].values / 1e4
        fig, ax = plt.subplots(figsize=(12, 4))
        ax.bar(
            df["date"],
            ch,
            color=[C["pos"] if v > 0 else C["neg"] for v in ch],
            alpha=0.8,
            width=0.8,
        )
        if len(ch) >= 5:
            ax.plot(
                df["date"],
                pd.Series(ch).rolling(5).mean(),
                color=C["orange"],
                linewidth=1.5,
                label="5日均值",
            )
        ax.axhline(y=0, color=C["grid"], linewidth=0.5)
        ax.set_ylabel("每日持股变化(万股)")
        ax.set_title(f"{code} {label}持股变化", fontsize=11)
        ax.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
        plt.xticks(rotation=45)
        ax.legend(loc="upper left", fontsize=8)
        ax.grid(True, alpha=0.3)
        path = os.path.join(out, "capital_flow.png")
        save_fig(fig, path)
        return path
    except Exception:
        return None


def chart_macro(macro, out):
    if not macro:
        return None
    try:
        dark_style()
        items = list(macro.items())
        n = len(items)
        cols = min(n, 4)
        rows = (n + cols - 1) // cols
        fig, axes = plt.subplots(rows, cols, figsize=(4 * cols, 2.5 * rows))
        if n == 1:
            axes = np.array([axes])
        axes = axes.flatten()
        for i, (name, d) in enumerate(items):
            ax = axes[i]
            chg = d.get("change_pct", 0)
            color = C["pos"] if chg > 0.05 else (C["neg"] if chg < -0.05 else C["text"])
            arrow = "▲" if chg > 0.05 else ("▼" if chg < -0.05 else "—")
            ax.text(
                0.5,
                0.65,
                f"{d['value']}",
                transform=ax.transAxes,
                ha="center",
                va="center",
                fontsize=18,
                fontweight="bold",
                color=C["text"],
            )
            ax.text(
                0.5,
                0.30,
                f"{arrow} {chg:+.2f}%",
                transform=ax.transAxes,
                ha="center",
                fontsize=10,
                color=color,
            )
            ax.text(
                0.5,
                0.08,
                name,
                transform=ax.transAxes,
                ha="center",
                fontsize=9,
                color="#888",
            )
            ax.set_xlim(0, 1)
            ax.set_ylim(0, 1)
            ax.set_xticks([])
            ax.set_yticks([])
        for j in range(i + 1, len(axes)):
            axes[j].set_visible(False)
        fig.suptitle("宏观环境", fontsize=12, y=1.02, color=C["text"])
        plt.tight_layout()
        path = os.path.join(out, "macro.png")
        save_fig(fig, path)
        return path
    except Exception:
        return None


def chart_valuation(info, code, out):
    pe = info.get("trailingPE")
    try:
        pe = float(pe)
    except (TypeError, ValueError):
        return None
    if pe is None or pe < 0:
        return None
    try:
        dark_style()
        fig, ax = plt.subplots(figsize=(8, 2.5))
        for x0, x1, c, l in [
            (0, 15, C["neg"], "便宜"),
            (15, 25, C["orange"], "合理"),
            (25, 45, C["pos"], "偏贵"),
        ]:
            ax.barh(0, x1 - x0, left=x0, height=0.5, color=c, alpha=0.25)
            ax.text((x0 + x1) / 2, -0.45, l, ha="center", fontsize=9, color="#888")
        ax.plot(pe, 0, marker="v", markersize=14, color=C["accent"], zorder=5)
        ax.text(
            pe,
            0.4,
            f"PE {pe:.1f}x",
            ha="center",
            fontsize=12,
            fontweight="bold",
            color=C["text"],
        )
        fwd = info.get("forwardPE")
        if fwd and fwd > 0:
            ax.plot(fwd, 0, marker="^", markersize=10, color=C["orange"], zorder=4)
            ax.text(
                fwd,
                -0.8,
                f"Forward {fwd:.1f}x",
                ha="center",
                fontsize=8,
                color=C["orange"],
            )
        ax.set_xlim(0, max(pe * 1.4, 45))
        ax.set_ylim(-1.2, 1.0)
        ax.set_yticks([])
        ax.set_xlabel("PE(TTM)")
        ax.set_title(f"{code} 估值水平", fontsize=11, pad=8)
        ax.grid(True, axis="x", alpha=0.3)
        path = os.path.join(out, "valuation.png")
        save_fig(fig, path)
        return path
    except Exception:
        return None


def chart_financials(code, out):
    if not HAS_YFINANCE:
        return None
    try:
        qf = _yf(code).quarterly_financials
        if qf is None or qf.empty:
            return None
        qf = qf.iloc[:, :8]
        rev = qf.loc["Total Revenue"] / 1e9 if "Total Revenue" in qf.index else None
        ni = qf.loc["Net Income"] / 1e9 if "Net Income" in qf.index else None
        if rev is None:
            return None
        dark_style()
        fig, ax1 = plt.subplots(figsize=(10, 4))
        qs = [f"{d.year%100}Q{(d.month-1)//3+1}" for d in reversed(rev.index)]
        rv = list(reversed(rev.values))
        x = range(len(qs))
        ax1.bar(x, rv, color=C["accent"], alpha=0.7, width=0.6, label="营收(十亿)")
        ax1.set_ylabel("营收(十亿)")
        ax1.set_xticks(x)
        ax1.set_xticklabels(qs, fontsize=8, rotation=45)
        if ni is not None:
            ax2 = ax1.twinx()
            nv = list(reversed(ni.values))
            ax2.plot(
                x,
                nv,
                color=C["orange"],
                linewidth=2,
                marker="o",
                markersize=5,
                label="净利润(十亿)",
            )
            ax2.set_ylabel("净利润(十亿)", color=C["orange"])
            ax2.tick_params(axis="y", labelcolor=C["orange"])
        ax1.set_title(f"{code} 季度营收与利润", fontsize=11, pad=8)
        ax1.grid(True, axis="y", alpha=0.3)
        l1, lb1 = ax1.get_legend_handles_labels()
        if ni is not None:
            l2, lb2 = ax2.get_legend_handles_labels()
            ax1.legend(l1 + l2, lb1 + lb2, loc="upper left", fontsize=8)
        else:
            ax1.legend(loc="upper left", fontsize=8)
        plt.tight_layout()
        path = os.path.join(out, "financials_trend.png")
        save_fig(fig, path)
        return path
    except Exception:
        return None


def chart_adr(code, adr_code, out, days=20):
    if not adr_code or not HAS_YFINANCE:
        return None
    try:
        per = f"{days+5}d"
        hk = _yf(code).history(period=per)
        adr = _yf(adr_code).history(period=per)
        fx = _yf("USDHKD=X").history(period=per)
        if hk.empty or adr.empty or fx.empty:
            return None
        com = hk.index.intersection(adr.index).intersection(fx.index)
        if len(com) < 5:
            return None
        prem = (
            (adr.loc[com, "Close"] * fx.loc[com, "Close"] - hk.loc[com, "Close"])
            / hk.loc[com, "Close"]
            * 100
        ).values
        dark_style()
        fig, ax = plt.subplots(figsize=(10, 3.5))
        ax.bar(
            com,
            prem,
            color=[C["pos"] if v > 0 else C["neg"] for v in prem],
            alpha=0.8,
            width=0.8,
        )
        ax.axhline(y=0, color=C["grid"], linewidth=0.5)
        ax.set_ylabel("ADR溢价/折价(%)")
        ax.set_title(f"{code} vs {adr_code} ADR溢价率", fontsize=11, pad=8)
        ax.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
        plt.xticks(rotation=45)
        ax.grid(True, axis="y", alpha=0.3)
        plt.tight_layout()
        path = os.path.join(out, "adr_premium.png")
        save_fig(fig, path)
        return path
    except Exception:
        return None


# ═══════════════════════════════════════════════
# 生成 report_template.md
# ═══════════════════════════════════════════════

# ── 图片数量控制 ──

MAX_IMAGES = 8


def _select_images(image_entries, output_dir):
    """按优先级选取图片，返回 {label: 原始路径}（不复制）。"""
    entries = [(l, p, pri) for l, p, pri in image_entries if p and os.path.exists(p)]
    entries.sort(key=lambda x: x[2])
    if len(entries) > MAX_IMAGES:
        entries = entries[:MAX_IMAGES]
        print(f"    ⚠ 图片超过{MAX_IMAGES}张上限，保留前{MAX_IMAGES}张")
    result = {}
    for label, path, _ in entries:
        result[label] = path
    return result


def _set_image_permission(path):
    """设置图片权限为 644，确保外部可访问"""
    try:
        os.chmod(path, 0o644)
    except Exception:
        pass


def _img_md(label, path):
    """生成 markdown 图片行（使用传入的路径）"""
    if not path:
        return ""
    # path 已经是相对路径或绝对路径，直接使用
    return f"![{label}]({path})"


def generate_template(code, charts, report_pages, ir_pages, capital_label, out):
    """生成带图片的报告骨架（绝对路径）"""

    # ── 收集所有图片，按优先级排序 ──
    image_entries = []
    if charts.get("kline"):
        image_entries.append(("K线图", charts["kline"], 1))
    if charts.get("kline_intraday"):
        image_entries.append(("分时图", charts["kline_intraday"], 2))
    if charts.get("valuation"):
        image_entries.append(("估值水平", charts["valuation"], 3))
    if charts.get("capital_flow"):
        image_entries.append((capital_label, charts["capital_flow"], 4))
    if charts.get("macro"):
        image_entries.append(("宏观环境", charts["macro"], 5))
    if charts.get("adr_premium"):
        image_entries.append(("ADR溢价", charts["adr_premium"], 6))
    if charts.get("financials_trend"):
        image_entries.append(("营收趋势", charts["financials_trend"], 7))
    for i, p in enumerate(report_pages or []):
        image_entries.append((f"研报图表{i+1}", p, 10 + i))
    for i, p in enumerate(ir_pages or []):
        image_entries.append((f"业绩演示{i+1}", p, 20 + i))

    # 数量控制
    print("  → 图片筛选...")
    img_map = _select_images(image_entries, out)

    # ── 生成模板 ──
    lines = []
    lines.append(f"# {{TITLE}}")
    lines.append("")
    lines.append("**一句话结论：** {VERDICT}")
    lines.append("")
    lines.append("---")
    lines.append("")

    # K线图
    img = _img_md("K线图", img_map.get("K线图"))
    if img:
        lines.append("## 技术面")
        lines.append("")
        lines.append(img)
        lines.append("")
        img2 = _img_md("分时图", img_map.get("分时图"))
        if img2:
            lines.append(img2)
            lines.append("")
        lines.append("{TECHNICAL_DESC}")
        lines.append("")

    # 估值图
    img = _img_md("估值水平", img_map.get("估值水平"))
    if img:
        lines.append("## 估值")
        lines.append("")
        lines.append(img)
        lines.append("")
        lines.append("{VALUATION_DESC}")
        lines.append("")

    # 营收趋势图
    img = _img_md("营收趋势", img_map.get("营收趋势"))
    if img:
        lines.append("## 营收趋势")
        lines.append("")
        lines.append(img)
        lines.append("")
        lines.append("{FINANCIALS_DESC}")
        lines.append("")

    # 资金流图
    img = _img_md(capital_label, img_map.get(capital_label))
    if img:
        lines.append(f"## {capital_label}")
        lines.append("")
        lines.append(img)
        lines.append("")
        lines.append("{CAPITAL_FLOW_DESC}")
        lines.append("")

    # ADR溢价图
    img = _img_md("ADR溢价", img_map.get("ADR溢价"))
    if img:
        lines.append("## ADR溢价")
        lines.append("")
        lines.append(img)
        lines.append("")
        lines.append("{ADR_DESC}")
        lines.append("")

    # 宏观图
    img = _img_md("宏观环境", img_map.get("宏观环境"))
    if img:
        lines.append("## 宏观环境")
        lines.append("")
        lines.append(img)
        lines.append("")
        lines.append("{MACRO_DESC}")
        lines.append("")

    # 券商研报图表
    report_imgs = [
        _img_md(f"研报图表{i+1}", img_map.get(f"研报图表{i+1}"))
        for i in range(len(report_pages or []))
    ]
    report_imgs = [x for x in report_imgs if x]
    if report_imgs:
        lines.append("## 券商研报图表")
        lines.append("")
        for x in report_imgs:
            lines.append(x)
            lines.append("")
        lines.append("{REPORT_DESC}")
        lines.append("")

    # IR 业绩演示图表
    ir_imgs = [
        _img_md(f"业绩演示{i+1}", img_map.get(f"业绩演示{i+1}"))
        for i in range(len(ir_pages or []))
    ]
    ir_imgs = [x for x in ir_imgs if x]
    if ir_imgs:
        lines.append("## 业绩演示图表")
        lines.append("")
        for x in ir_imgs:
            lines.append(x)
            lines.append("")
        lines.append("{IR_DESC}")
        lines.append("")
    else:
        lines.append("## 业绩演示图表")
        lines.append("")
        lines.append("{IR_CHARTS}")
        lines.append("")

    # 事件列表
    lines.append("## 近期关键事件")
    lines.append("")
    lines.append("{EVENTS}")
    lines.append("")

    # 分隔线
    lines.append("---")
    lines.append("")
    lines.append("═══ 以上是证据，以下是判断 ═══")
    lines.append("")

    # 判断区
    lines.append("## 综合判断")
    lines.append("")
    lines.append("{JUDGMENT}")
    lines.append("")
    lines.append("## 什么会改变我的判断")
    lines.append("")
    lines.append("{CHANGE_TRIGGERS}")
    lines.append("")
    lines.append("---")
    lines.append("")
    lines.append("> ⚠️ 以上分析仅供参考，不构成投资建议。")

    content = "\n".join(lines)
    path = os.path.join(out, "report_template.md")
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)

    size_kb = os.path.getsize(path) // 1024
    print(f"    📝 report_template.md: {size_kb}KB")
    return path


# ═══════════════════════════════════════════════
# 主函数
# ═══════════════════════════════════════════════


def _fetch_adr(adr_code):
    """ADR 数据（独立函数，供并行调用）"""
    if not adr_code or not HAS_YFINANCE:
        return {}
    try:
        h = _yf(adr_code).history(period="5d")
        if not h.empty:
            fx = _yf("USDHKD=X").history(period="1d")
            rate = float(fx["Close"].iloc[-1]) if not fx.empty else 7.80
            return {
                "adr_close_usd": round(float(h["Close"].iloc[-1]), 2),
                "usd_hkd_rate": round(rate, 4),
            }
    except Exception:
        pass
    return {}


def _download_em_kline_multi(market, pure, out):
    """东方财富多周期K线图+分时图直接下载（日K/周K/月K/分时）

    东方财富图片服务器支持的 imageType:
      K=日K, RTOPH=分时, KL=迷你K线
    周K/月K图片服务器不支持，需要从API获取数据后本地绘制。
    """
    _PIC_CHARTS = {
        "kline": ("K", "kline_em.png", "东方财富日K线图"),
        "intraday": ("RTOPH", "kline_intraday.png", "东方财富分时图"),
    }
    results = {}
    if not market or not pure:
        return results
    nid = _em_nid(market, pure)
    if not nid:
        return results

    # 1. 直接下载图片服务器支持的类型（日K + 分时）
    for key, (img_type, filename, label) in _PIC_CHARTS.items():
        path = os.path.join(out, filename)
        if _download_em_pic(nid, img_type, path):
            results[key] = path
            print(f"    ✅ {label}直链")

    # 2. 周K/月K：从东方财富K线API获取数据 → matplotlib本地绘制
    for key, klt_val, label, filename in [
        ("kline_weekly", "102", "周K线图", "kline_weekly.png"),
        ("kline_monthly", "103", "月K线图", "kline_monthly.png"),
    ]:
        path = os.path.join(out, filename)
        drawn = _draw_period_kline(nid, klt_val, path, label)
        if drawn:
            results[key] = path
    return results


def _draw_period_kline(nid, klt, save_path, label=""):
    """从东方财富K线API获取数据，用matplotlib绘制指定周期K线图"""
    try:
        # klt: 101=日, 102=周, 103=月
        api_url = (
            f"https://push2.eastmoney.com/api/qt/stock/kline/get?"
            f"secid={nid}&fields1=f1,f2,f3,f4,f5,f6&"
            f"fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&"
            f"klt={klt}&fqt=1&end=20500101&lmt=300"
        )
        req = urllib.request.Request(api_url, headers=_EM_PIC_HEADERS)
        data = json.loads(urllib.request.urlopen(req, timeout=15).read())
        klines = data.get("data", {}).get("klines", [])
        if len(klines) < 5:
            print(f"    ⚠ {label}: 数据不足({len(klines)}条)")
            return None

        # 解析K线数据
        records = []
        for line in klines:
            parts = line.split(",")
            if len(parts) >= 11:
                records.append(
                    {
                        "date": parts[0],
                        "open": float(parts[1]),
                        "close": float(parts[2]),
                        "high": float(parts[3]),
                        "low": float(parts[4]),
                        "volume": float(parts[6]),
                    }
                )

        df = pd.DataFrame(records)
        df["date"] = pd.to_datetime(df["date"])

        # 用matplotlib绘制K线图（不依赖mplfinance）
        fig, (ax1, ax2) = plt.subplots(
            2, 1, figsize=(14, 7), gridspec_kw={"height_ratios": [3, 1]}, sharex=True
        )
        dark_style()
        fig.patch.set_facecolor(C["bg"])
        ax1.set_facecolor(C["surface"])
        ax2.set_facecolor(C["surface"])

        dates = range(len(df))
        colors = [
            C["pos"] if c >= o else C["neg"] for o, c in zip(df["open"], df["close"])
        ]

        # 画实体
        for i, row in df.iterrows():
            color = C["pos"] if row["close"] >= row["open"] else C["neg"]
            ax1.plot([i, i], [row["low"], row["high"]], color=color, linewidth=0.8)
            rect_bottom = min(row["open"], row["close"])
            rect_height = abs(row["close"] - row["open"]) or 0.5
            ax1.bar(
                i,
                rect_height,
                bottom=rect_bottom,
                color=color,
                width=0.7,
                edgecolor=color,
            )

        # 均线
        for ma_len, color, name in [(5, "#FFD700", "MA5"), (20, "#00BFFF", "MA20")]:
            if len(df) >= ma_len:
                ma = df["close"].rolling(ma_len).mean()
                ax1.plot(dates, ma, color=color, linewidth=1.2, label=name, alpha=0.85)

        ax1.set_title(f"\n{label}", fontsize=14, color=C["text"], fontweight="bold")
        ax1.legend(loc="upper left", fontsize=9)
        ax1.grid(True, alpha=0.25)
        ax1.set_ylabel("价格", color=C["text"])

        # 成交量
        vol_colors = [
            C["pos"] if c >= o else C["neg"]
            for o, c in zip(df["open"].iloc[-len(df) :], df["close"].iloc[-len(df) :])
        ]
        ax2.bar(dates, df["volume"], color=vol_colors, width=0.7, alpha=0.8)
        ax2.set_ylabel("成交量", color=C["text"])
        ax2.grid(True, alpha=0.25)

        # X轴日期标签
        n_bars = len(df)
        tick_step = max(1, n_bars // 12)
        tick_pos = list(range(0, n_bars, tick_step))
        if n_bars - 1 not in tick_pos:
            tick_pos.append(n_bars - 1)
        ax2.set_xticks(tick_pos)
        ax2.set_xticklabels(
            [
                (
                    df.iloc[i]["date"].strftime("%m/%d")
                    if hasattr(df.iloc[i]["date"], "strftime")
                    else str(df.iloc[i]["date"])[:10]
                )
                for i in tick_pos
            ],
            rotation=45,
            fontsize=8,
        )

        plt.tight_layout()
        save_fig(fig, save_path)
        print(f"    ✅ {label}: 本地绘制({len(df)}根K线)")
        return save_path

    except urllib.error.URLError as e:
        print(f"    ⚠ {label}: 网络错误({e})")
        return None
    except Exception as e:
        print(f"    ⚠ {label}: {e}")
        return None


def main():
    from concurrent.futures import ThreadPoolExecutor, as_completed

    parser = argparse.ArgumentParser(description="股票数据获取 v28.0")
    parser.add_argument("stock", help="股票代码 (如 0700.HK, 600519.SS, AAPL)")
    parser.add_argument("--adr", default="", help="ADR代码 (如 TCEHY)")
    parser.add_argument(
        "--output-dir", default="", help="输出目录（默认为当前工作目录）"
    )
    args = parser.parse_args()

    code = args.stock
    market, pure = _detect_market(code)
    ts = datetime.now().strftime("%Y%m%d_%H%M")
    # 输出目录：--output-dir 参数 > os.getcwd()
    if args.output_dir:
        output_base = args.output_dir
    else:
        output_base = os.getcwd()
    out = os.path.abspath(os.path.join(output_base, f"stock_data_output/{pure}_{ts}"))
    os.makedirs(out, exist_ok=True)

    TOTAL_TIMEOUT = 60  # 秒，留30秒给图表生成+写文件

    print(f"📊 获取 {code} 的全部数据（市场: {market}）...")
    print(f"  ⏱ 数据获取超时: {TOTAL_TIMEOUT}秒（所有数据源并行）")

    # ═══════════════════════════════════════
    # Phase 1: 并行获取所有数据（60秒总超时）
    # ═══════════════════════════════════════
    print("\n  ── Phase 1: 并行数据获取 ──")

    with ThreadPoolExecutor(max_workers=15) as pool:
        futures = {}

        # K线 — 4个源并行
        futures[pool.submit(kline_tushare, code, market, pure)] = "kline_tushare"
        futures[pool.submit(kline_akshare, code, market, pure)] = "kline_akshare"
        futures[pool.submit(kline_eastmoney, code, market, pure)] = "kline_eastmoney"
        futures[pool.submit(kline_yfinance, code)] = "kline_yfinance"

        # 基本面 — 3个源并行
        futures[pool.submit(_fetch_info_eastmoney, market, pure)] = "info_eastmoney"
        futures[pool.submit(_fetch_info_yfinance, code)] = "info_yfinance"
        futures[pool.submit(_fetch_info_tushare, market, pure)] = "info_tushare"

        # 其他 — 各自独立
        futures[pool.submit(fetch_capital_flow, code, market, pure)] = "capital_flow"
        futures[pool.submit(fetch_main_capital_flow, code, market, pure)] = (
            "main_capital_flow"
        )
        futures[pool.submit(fetch_cls_news, code)] = "cls_news"
        futures[pool.submit(fetch_reports, pure)] = "reports"
        futures[pool.submit(fetch_macro)] = "macro"
        futures[pool.submit(_fetch_adr, args.adr)] = "adr"
        futures[pool.submit(_download_em_kline_multi, market, pure, out)] = (
            "em_kline_pic"
        )
        futures[pool.submit(fetch_ir_presentation, code, pure, market, out)] = (
            "ir_presentation"
        )

        # 收割结果
        raw = {}
        try:
            for future in as_completed(futures, timeout=TOTAL_TIMEOUT):
                name = futures[future]
                try:
                    raw[name] = future.result(timeout=5)
                except Exception as e:
                    print(f"    ⚠ {name} 失败: {e}")
                    raw[name] = None
        except Exception:
            # 总超时到了，收集已完成的
            for future, name in futures.items():
                if future.done():
                    try:
                        raw[name] = future.result(timeout=0)
                    except Exception:
                        raw[name] = None
                else:
                    print(f"    ⏰ {name} 超时未返回")
                    raw[name] = None

    # ── 整理结果 ──
    # K线：按优先级选最佳
    kline = None
    kline_src = "none"
    kline_priority = (
        ["kline_eastmoney", "kline_akshare", "kline_tushare", "kline_yfinance"]
        if market in ("hk", "sh", "sz")
        else ["kline_yfinance", "kline_akshare", "kline_tushare"]
    )
    for key in kline_priority:
        if raw.get(key) is not None:
            kline = raw[key]
            kline_src = key.replace("kline_", "")
            break
    print(f"  K线来源: {kline_src}" if kline is not None else "  ⚠ 无K线数据")

    # 基本面：合并三个源
    info = _merge_info(
        raw.get("info_eastmoney") or {},
        raw.get("info_yfinance") or {},
        raw.get("info_tushare") or {},
    )

    # 资金流
    cap_result = raw.get("capital_flow") or ([], "N/A")
    cap_flow = cap_result[0] if isinstance(cap_result, tuple) else []
    cap_label = cap_result[1] if isinstance(cap_result, tuple) else "N/A"

    # 其他
    reports = raw.get("reports") or []
    macro = raw.get("macro") or {}
    adr_data = raw.get("adr") or {}
    em_pics = raw.get("em_kline_pic") or {"kline": None, "intraday": None}
    ir_pages = raw.get("ir_presentation") or []
    cls_news = raw.get("cls_news") or []
    main_cap = raw.get("main_capital_flow") or {}

    def _has_data(v):
        if v is None:
            return False
        if isinstance(v, (pd.DataFrame, pd.Series)):
            return not v.empty
        if isinstance(v, (list, dict)):
            return len(v) > 0
        return True

    got = sum(
        1
        for v in [kline, info, cap_flow, reports, macro, adr_data, cls_news, main_cap]
        if _has_data(v)
    )
    print(f"  Phase 1 完成: {got}/8 类数据获取成功")

    # ═══════════════════════════════════════
    # Phase 2: 串行生成图表（本地操作，秒级）
    # ═══════════════════════════════════════
    print("\n  ── Phase 2: 生成图表 ──")
    tech = compute_technicals(kline)

    charts = {}
    # K线图：优先用东方财富直链图片（含日K/周K/月K），否则本地绘制
    _kline_set = False
    for k in ["kline", "kline_weekly", "kline_monthly"]:
        if em_pics.get(k):
            charts[k] = em_pics[k]
            _kline_set = True
            label = {"kline": "日K", "kline_weekly": "周K", "kline_monthly": "月K"}.get(
                k, "K"
            )
            print(f"    ✅ {label}: 东方财富直链")
    if not _kline_set and kline is not None:
        charts["kline"] = chart_kline(kline, code, out)

    if em_pics.get("intraday"):
        charts["kline_intraday"] = em_pics["intraday"]

    charts["capital_flow"] = chart_capital_flow(cap_flow, code, out, label=cap_label)
    charts["macro"] = chart_macro(macro, out)
    charts["valuation"] = chart_valuation(info, code, out)
    charts["financials_trend"] = chart_financials(code, out)
    charts["adr_premium"] = chart_adr(code, args.adr, out) if args.adr else None

    chart_count = sum(1 for v in charts.values() if v)
    for name, path in charts.items():
        if path:
            print(f"    ✅ {name}: {os.path.basename(path)}")

    # 研报PDF图表
    print("  → 研报PDF图表...")
    stock_name = info.get("shortName", "")
    report_pages = auto_fetch_report_pdf(
        reports, out, stock_name=stock_name, stock_code=pure, max_reports=2, max_pages=4
    )

    # ═══════════════════════════════════════
    # Phase 3: 写文件（秒级）
    # ═══════════════════════════════════════
    print("\n  ── Phase 3: 写文件 ──")

    # 资金流汇总
    cap_summary = {}
    if cap_flow:
        recent = cap_flow[-5:] if len(cap_flow) >= 5 else cap_flow
        if recent:
            d = 1 if recent[-1]["shares_change"] > 0 else -1
            cons = 0
            for x in reversed(recent):
                if (x["shares_change"] > 0 and d > 0) or (
                    x["shares_change"] < 0 and d < 0
                ):
                    cons += 1
                else:
                    break
            cap_summary = {
                "type": cap_label,
                "consecutive_days": cons,
                "direction": "净买入" if d > 0 else "净卖出",
                "recent_total_万股": round(
                    sum(x["shares_change"] for x in recent) / 1e4, 1
                ),
            }

    result = {
        "stock_code": code,
        "market": market,
        "fetch_time": datetime.now().isoformat(),
        "kline_source": kline_src,
        "financials": info,
        "technicals": tech,
        "capital_flow_label": cap_label,
        "capital_flow_summary": cap_summary,
        "main_capital_flow": main_cap,
        "cls_news": cls_news[:10],
        "analyst_reports": reports[:10],
        "report_chart_pages": report_pages,
        "ir_presentation_pages": ir_pages,
        "adr": adr_data,
        "macro": macro,
        "charts": {k: v for k, v in charts.items() if v},
        "chart_count": chart_count + len(report_pages) + len(ir_pages),
    }

    with open(os.path.join(out, "data.json"), "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=2, default=str)

    # ── Phase 3b: 整理图片到固定目录，输出数据摘要 ──
    # ── Phase 3b: 整理输出（图片保留在任务目录中）──
    img_paths = {}
    for key, path in charts.items():
        if path and os.path.exists(path):
            img_paths[key] = path
    for i, p in enumerate(report_pages or []):
        if p and os.path.exists(p):
            img_paths[f"report_{i+1}"] = p
    for i, p in enumerate(ir_pages or []):
        if p and os.path.exists(p):
            img_paths[f"ir_{i+1}"] = p

    print(f"  📸 图片: {len(img_paths)} 张（在任务目录 {out} 中）")
    # 构建数据摘要（输出到 stdout，供主模型读取）
    # ── 文本折线图（Webchat 内嵌展示用）──
    _tc = {}
    if (
        HAS_TEXT_CHART
        and kline is not None
        and not isinstance(kline, str)
        and hasattr(kline, "shape")
    ):
        try:
            _closes = kline["close"].tolist() if "close" in kline.columns else []
            _dates = (
                kline.index.strftime("%m/%d").tolist()
                if hasattr(kline.index, "strftime")
                else list(range(len(_closes)))
            )
            if len(_closes) >= 5:
                _tc["sparkline_20d"] = sparkline(_closes[-20:])
                _tc["sparkline_60d"] = sparkline(
                    _closes[-60:] if len(_closes) >= 60 else _closes
                )
                _tc["block_chart_30d"] = block_chart(
                    _closes[-30:],
                    labels=_dates[-30:] if len(_dates) >= 30 else _dates,
                    height=8,
                    width=35,
                )
        except Exception:
            pass

    summary = {
        "stock_code": code,
        "stock_name": info.get("shortName", ""),
        "market": market,
        "output_dir": out,
        "fetch_time": datetime.now().isoformat(),
        "kline_source": kline_src,
        "images": img_paths,
        "data": {
            "financials": {
                k: v
                for k, v in info.items()
                if v is not None and str(v) != "" and not isinstance(v, pd.DataFrame)
            },
            "technicals": tech,
            "capital_flow_summary": cap_summary,
            "main_capital_flow": main_cap,
            "cls_news": cls_news[:10],
            "analyst_reports": reports[:5],
            "macro": macro,
            "adr": adr_data,
        },
        "text_charts": _tc,
        "export_options": {
            "pdf_cmd": f"python3 {os.path.abspath('export_report.py')} {{out}}/report.md --format pdf",
            "html_cmd": f"python3 {os.path.abspath('export_report.py')} {{out}}/report.md --format html",
            "open_html": f"open {{out}}/report.html",
        },
    }

    summary_path = os.path.join(out, "summary.json")
    with open(summary_path, "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, default=str)

    total = chart_count + len(report_pages) + len(ir_pages)
    print(f"\n{'='*60}")
    print(f"✅ 完成! 共 {total} 张图表")
    print(f"  📄 data.json:      {os.path.join(out,'data.json')}")
    print(f"  📋 summary.json:   {summary_path}")
    print(f"  📸 输出目录:       {out}")
    print(f"{'='*60}")

    if total == 0:
        print("  ⚠ 未生成任何图表")

    # 输出摘要 JSON 到 stdout（主模型可解析）
    print("\n__SUMMARY_BEGIN__")
    print(json.dumps(summary, ensure_ascii=False, indent=2, default=str))
    print("__SUMMARY_END__")

    # HTML 报告由主模型分析完成后生成并写入 {out}/report.html
    # 脚本只负责数据采集+画图，不生成报告


def _generate_data_html(summary, html_path):
    """生成数据概览 HTML（含图表+数据），脚本自动生成并在浏览器中打开。"""
    import html as _html_mod

    code = summary.get("stock_code", "")
    name = summary.get("stock_name") or code
    market = summary.get("market", "")
    images = summary.get("images", {})
    data = summary.get("data", {})
    fetch_time = summary.get("fetch_time", "")
    tc = summary.get("text_charts") or {}

    def esc(s):
        if not s:
            return ""
        return _html_mod.escape(str(s))

    # 图片列表
    img_order = [
        "kline",
        "kline_weekly",
        "kline_monthly",
        "kline_intraday",
        "valuation",
        "capital_flow",
        "macro",
        "adr_premium",
        "financials_trend",
    ]
    img_labels_map = {
        "kline": "日K线图",
        "kline_weekly": "周K线图",
        "kline_monthly": "月K线图",
        "kline_intraday": "分时图",
        "valuation": "估值图",
        "capital_flow": "资金流向图",
        "macro": "宏观环境图",
        "adr_premium": "ADR溢价图",
        "financials_trend": "营收趋势图",
    }
    img_items = []
    for key in img_order:
        path = images.get(key)
        if path and os.path.exists(path):
            img_items.append((img_labels_map.get(key, key), path))
    for key, path in images.items():
        if path and os.path.exists(path) and key not in img_order:
            img_items.append((key.replace("_", " ").title(), path))

    from io import StringIO

    buf = StringIO()
    w = buf.write

    # === HTML HEAD ===
    w('<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n')
    w(
        '<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
    )
    w("<title>" + esc(name) + " (" + esc(code) + ") \u2014 数据概览</title>\n<style>\n")
    w("*{margin:0;padding:0;box-sizing:border-box;}\n")
    w(
        'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;line-height:1.8;background:#f0f2f5;color:#333;}\n'
    )
    w(".container{max-width:920px;margin:0 auto;padding:20px;}\n")
    w(
        ".card{background:white;border-radius:12px;padding:36px;box-shadow:0 2px 12px rgba(0,0,0,0.08);margin-bottom:20px;}\n"
    )
    w(
        "h1{font-size:24px;color:#1a1a1a;border-bottom:3px solid #1e88e5;padding-bottom:10px;}\n"
    )
    w(".meta{color:#888;font-size:13px;margin-bottom:28px;}\n")
    w(
        "h2{font-size:18px;color:#1e88e5;margin-top:32px;margin-bottom:14px;padding-left:12px;border-left:4px solid #1e88e5;}\n"
    )
    w("table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13px;}\n")
    w("th,td{border:1px solid #e8eaed;padding:10px 12px;text-align:left;}\n")
    w("th{background:#1e88e5;color:white;font-weight:600;}\n")
    w("tr:nth-child(even){background:#f8f9fa;}\n")
    w(
        ".chart-box{text-align:center;margin:22px 0;background:#fafbfc;border:1px solid #e8eaed;border-radius:8px;padding:18px;}\n"
    )
    w(".chart-box img{max-width:100%;height:auto;border-radius:6px;}\n")
    w(".chart-caption{color:#888;font-size:13px;margin-top:10px;}\n")
    w(
        '.sparkline-box{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 20px;margin:20px 0;font-family:"SF Mono",Monaco,monospace;font-size:17px;letter-spacing:2px;text-align:center;}\n'
    )
    w(".news-item{border-bottom:1px solid #f0f0f0;padding:12px 0;}\n")
    w(".news-item:last-child{border:none;}\n")
    w(".news-title{font-weight:600;font-size:14px;color:#1a1a1a;}\n")
    w(".news-meta{color:#999;font-size:12px;margin:3px 0;}\n")
    w(".news-content{color:#666;font-size:13px;margin-top:4px;line-height:1.6;}\n")
    w(
        ".hint{background:linear-gradient(135deg,#eff6ff 0%,#dbeafe 100%);border:1px solid #bfdbfe;border-radius:8px;padding:18px 22px;margin-top:28px;font-size:13px;color:#1e40af;line-height:1.7;}\n"
    )
    w(
        ".footer{text-align:center;color:#aaa;font-size:12px;margin-top:24px;padding-top:16px;border-top:1px solid #e8eaed;}\n"
    )
    w(
        ".empty{color:#999;font-style:italic;padding:20px;text-align:center;background:#fafbfc;border-radius:6px;margin:10px 0;}\n"
    )
    w('</style>\n</head>\n<body>\n<div class="container">\n<div class="card">\n')

    # 标题
    w("<h1>📊 " + esc(name) + " (" + esc(code) + ") 数据概览</h1>\n")
    w(
        '<div class="meta"><strong>采集时间：</strong>'
        + fetch_time[:19]
        + " &nbsp;|&nbsp; <strong>市场：</strong>"
        + market.upper()
        + " &nbsp;|&nbsp; <strong>状态：</strong>原始数据采集完成，待主模型分析</div>\n"
    )

    # 文本折线图
    sp = tc.get("sparkline_60d") or tc.get("sparkline_20d") or ""
    if sp:
        w('<div class="sparkline-box">近期走势：' + esc(sp) + "</div>\n")

    # 图表
    w("<h2>📈 图表</h2>\n")
    if img_items:
        for label, path in img_items:
            w(
                '<div class="chart-box"><img src="file://'
                + path
                + '" alt="'
                + label
                + '" onerror="this.onerror=null;this.parentElement.style.display=\'none\'">'
                + '<div class="chart-caption">'
                + label
                + "</div></div>\n"
            )
    else:
        w('<p class="empty">本次未生成图表</p>\n')

    # 基本面
    w("<h2>💰 基本面数据</h2>\n")
    fin = data.get("financials") or {}
    fin_rows = ""
    if isinstance(fin, dict):
        for k, v in list(fin.items())[:15]:
            if v is not None and str(v).strip() and not str(v).startswith("{"):
                fin_rows += (
                    "<tr><td>" + esc(k) + "</td><td>" + esc(str(v)) + "</td></tr>"
                )
    if fin_rows:
        w("<table><tr><th>指标</th><th>数值</th></tr>" + fin_rows + "</table>\n")
    else:
        w('<p class="empty">未获取到基本面数据</p>\n')

    # 技术指标
    w("<h2>📐 技术指标</h2>\n")
    tech = data.get("technicals") or {}
    tech_rows = ""
    if isinstance(tech, dict):
        for k, v in list(tech.items())[:12]:
            if v is not None:
                tech_rows += (
                    "<tr><td>" + esc(k) + "</td><td>" + esc(str(v)) + "</td></tr>"
                )
    if tech_rows:
        w("<table><tr><th>指标</th><th>数值</th></table>" + tech_rows + "</table>\n")
    else:
        w('<p class="empty">未获取到技术指标</p>\n')

    # 资金流向
    w("<h2>💵 资金流向</h2>\n")
    mcf = data.get("main_capital_flow") or {}
    if isinstance(mcf, dict) and mcf.get("total_main_net_inflow") is not None:
        total = mcf.get("total_main_net_inflow", 0)
        days = mcf.get("consecutive_days", 0)
        direction = mcf.get("direction", "")
        w(
            f"<p><strong>{esc(direction)}:</strong> {total/1e8:.2f}亿 | 连续{days}天</p>\n"
        )
    cap = data.get("capital_flow_summary") or {}
    if isinstance(cap, dict) and cap:
        w(
            f'<p><strong>方向:</strong> {esc(cap.get("direction","N/A"))} | <strong>连续:</strong> {str(cap.get("consecutive_days","N/A"))}天</p>\n'
        )
    if (not mcf) and (not cap):
        w('<p class="empty">未获取到资金流数据</p>\n')

    # 研报
    w("<h2>📑 券商研报（最新）</h2>\n")
    rpt_rows = ""
    for r in (data.get("analyst_reports") or [])[:6]:
        rpt_rows += (
            "<tr><td>"
            + esc(r.get("org", ""))
            + "</td><td>"
            + esc(r.get("rating", ""))
            + "</td><td>"
            + esc(r.get("title", ""))
            + "</td><td>"
            + str(r.get("date", ""))
            + "</td></tr>"
        )
    if rpt_rows:
        w(
            "<table><tr><th>机构</th><th>评级</th><th>标题</th><th>日期</th></tr>"
            + rpt_rows
            + "</table>\n"
        )
    else:
        w('<p class="empty">未获取到研报数据</p>\n')

    # 新闻
    w("<h2>📰 近期新闻</h2>\n")
    news_divs = ""
    for n in (data.get("cls_news") or [])[:10]:
        title = n.get("title", "")
        date = str(n.get("date", ""))[:16]
        source = n.get("source", "")
        content_preview = (n.get("content") or "")[:120]
        news_divs += (
            '<div class="news-item"><div class="news-title">'
            + esc(title)
            + '</div><div class="news-meta">'
            + date
            + " · "
            + esc(source)
            + '</div><div class="news-content">'
            + esc(content_preview)
            + "...</div></div>"
        )
    if news_divs:
        w(news_divs + "\n")
    else:
        w('<p class="empty">未获取到新闻</p>\n')

    # 提示 + footer
    w(
        '<div class="hint"><strong>💡 下一步：</strong>多模态主模型将基于以上数据和图片，<br>'
    )
    w("输出完整的分析报告到聊天窗口中。<br>")
    w('如需导出 <strong>PDF 报告</strong>，请在聊天中回复"导出 PDF"。</div>\n')
    w(
        '<div class="footer">数据由 stock-analyst skill 自动采集 · '
        + fetch_time[:19]
        + "</div>\n"
    )
    w("</div></div></body></html>\n")

    with open(html_path, "w", encoding="utf-8") as f:
        f.write(buf.getvalue())


if __name__ == "__main__":
    main()


path: scripts/fetch_ir.py
content: #!/usr/bin/env python3
"""
IR 文档获取 + PDF 关键页面提取

功能：
  1. 给 URL → 下载 PDF
  2. PDF 每页转 PNG
  3. 启发式筛选图表密集页（跳过纯文字页和封面/法律页）
  4. 输出筛选后的 PNG 列表，供模型视觉查看

用法：
  python3 fetch_ir.py --url "https://example.com/report.pdf" --output-dir ./ir_output
  python3 fetch_ir.py --file ./local_report.pdf --output-dir ./ir_output

依赖：
  pip3 install pymupdf
"""

import argparse
import json
import os
import sys
import urllib.request
from urllib.parse import urlparse

try:
    import fitz  # pymupdf
except ImportError:
    print("❌ 需要安装 pymupdf: pip3 install pymupdf", file=sys.stderr)
    sys.exit(1)


# 允许的 URL scheme（仅 HTTPS，防止 file:// 等 SSRF）
_ALLOWED_SCHEMES = {"https"}
# 最大下载文件大小（50 MB）
_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024


def download_pdf(url: str, output_path: str, timeout: int = 30) -> bool:
    """下载 PDF 文件（仅限 HTTPS）"""
    parsed = urlparse(url)
    if parsed.scheme not in _ALLOWED_SCHEMES:
        print(
            f"  ❌ 不安全的 URL scheme: {parsed.scheme}（仅支持 HTTPS）",
            file=sys.stderr,
        )
        return False
    try:
        req = urllib.request.Request(
            url,
            headers={
                "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
            },
        )
        resp = urllib.request.urlopen(req, timeout=timeout)
        # 分块读取，限制最大大小
        chunks = []
        total = 0
        while True:
            chunk = resp.read(1024 * 1024)  # 1 MB chunks
            if not chunk:
                break
            total += len(chunk)
            if total > _MAX_DOWNLOAD_BYTES:
                print(
                    f"  ❌ 文件超过大小限制 ({_MAX_DOWNLOAD_BYTES // 1024 // 1024} MB)",
                    file=sys.stderr,
                )
                return False
            chunks.append(chunk)
        with open(output_path, "wb") as f:
            f.write(b"".join(chunks))
        size_mb = os.path.getsize(output_path) / 1e6
        print(f"  ✅ 下载完成: {size_mb:.1f} MB")
        return True
    except Exception as e:
        print(f"  ❌ 下载失败: {e}", file=sys.stderr)
        return False


def pdf_to_pages(
    pdf_path: str, output_dir: str, dpi: int = 200, max_pages: int = 50
) -> list[dict]:
    """
    PDF 每页转 PNG，同时分析每页的图表密度。
    返回每页的元信息列表。max_pages 限制最大处理页数。
    """
    doc = fitz.open(pdf_path)
    total = len(doc)
    if total > max_pages:
        print(f"  ⚠️ PDF 共 {total} 页，仅处理前 {max_pages} 页")
        total = max_pages
    else:
        print(f"  📄 PDF 共 {total} 页，正在转换...")

    pages = []
    for i in range(total):
        page = doc[i]

        # 转 PNG
        mat = fitz.Matrix(dpi / 72, dpi / 72)
        pix = page.get_pixmap(matrix=mat)
        png_path = os.path.join(output_dir, f"page_{i+1:03d}.png")
        pix.save(png_path)

        # 分析页面特征
        text = page.get_text()
        text_len = len(text.strip())
        images = page.get_images(full=True)
        image_count = len(images)

        # 检测绘图对象（矢量图表通常以 drawing 形式存在）
        drawings = page.get_drawings()
        drawing_count = len(drawings)

        # 页面尺寸
        rect = page.rect
        page_area = rect.width * rect.height

        # 启发式：图表密度评分
        # 图片多 or 矢量绘图多 → 可能是图表页
        # 文字极少 → 可能是封面/分隔页（排除）
        # 文字极多且无图 → 纯文字页（排除）
        chart_score = 0

        if image_count >= 1:
            chart_score += 3 * min(image_count, 5)
        if drawing_count >= 10:
            chart_score += min(drawing_count / 10, 5)
        if 50 < text_len < 500:
            chart_score += 2  # 适量文字+图，典型图表页
        if text_len < 20:
            chart_score -= 3  # 几乎无文字，可能是封面或空白页
        if text_len > 2000 and image_count == 0:
            chart_score -= 3  # 纯文字页

        # 跳过第一页（通常是封面）和最后两页（通常是免责声明）
        if i == 0:
            chart_score -= 2
        if i >= total - 2:
            chart_score -= 2

        pages.append(
            {
                "page_num": i + 1,
                "png_path": png_path,
                "text_length": text_len,
                "image_count": image_count,
                "drawing_count": drawing_count,
                "chart_score": round(chart_score, 1),
                "first_50_chars": text[:50].replace("\n", " ").strip(),
            }
        )

    doc.close()
    return pages


def select_key_pages(pages: list[dict], max_pages: int = 8) -> list[dict]:
    """
    从所有页面中筛选出最可能包含图表的 N 页。
    按 chart_score 降序排列，取前 max_pages 页，然后按页码重新排序。
    """
    # 过滤掉明显的非图表页
    candidates = [p for p in pages if p["chart_score"] > 0]

    # 如果筛选后不够，放宽条件
    if len(candidates) < 3:
        candidates = [p for p in pages if p["chart_score"] > -2]

    # 按 chart_score 降序取前 N
    candidates.sort(key=lambda x: x["chart_score"], reverse=True)
    selected = candidates[:max_pages]

    # 按页码重新排序（方便阅读）
    selected.sort(key=lambda x: x["page_num"])

    return selected


def main():
    parser = argparse.ArgumentParser(description="IR 文档获取 + 关键页面提取")
    parser.add_argument("--url", type=str, help="PDF 下载 URL")
    parser.add_argument("--file", type=str, help="本地 PDF 文件路径")
    parser.add_argument("--output-dir", default="./ir_output", help="输出目录")
    parser.add_argument(
        "--max-pages", type=int, default=8, help="最多提取几页关键图表页"
    )
    parser.add_argument("--dpi", type=int, default=200, help="图片分辨率")
    args = parser.parse_args()

    if not args.url and not args.file:
        print("❌ 必须提供 --url 或 --file 参数", file=sys.stderr)
        sys.exit(1)

    out = args.output_dir
    os.makedirs(out, exist_ok=True)

    # Step 1: 获取 PDF
    if args.url:
        pdf_path = os.path.join(out, "report.pdf")
        print(f"📥 下载 PDF: {args.url[:80]}...")
        if not download_pdf(args.url, pdf_path):
            sys.exit(1)
    else:
        pdf_path = args.file
        if not os.path.exists(pdf_path):
            print(f"❌ 文件不存在: {pdf_path}", file=sys.stderr)
            sys.exit(1)

    # Step 2: 转换所有页面
    all_pages = pdf_to_pages(pdf_path, out, dpi=args.dpi, max_pages=50)

    # Step 3: 筛选关键图表页
    key_pages = select_key_pages(all_pages, max_pages=args.max_pages)

    # Step 4: 保存结果
    result = {
        "pdf_path": pdf_path,
        "total_pages": len(all_pages),
        "selected_count": len(key_pages),
        "selected_pages": key_pages,
        "all_pages_summary": [
            {
                "page": p["page_num"],
                "score": p["chart_score"],
                "preview": p["first_50_chars"][:30],
            }
            for p in all_pages
        ],
    }

    json_path = os.path.join(out, "ir_summary.json")
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=2, default=str)

    # Step 5: 打印结果
    print(f"\n✅ 完成! 共 {len(all_pages)} 页，筛选出 {len(key_pages)} 页关键图表页：")
    print()
    for p in key_pages:
        print(f"  📊 第{p['page_num']}页 (score={p['chart_score']}) → {p['png_path']}")
        print(
            f"     {p['image_count']}张图片, {p['drawing_count']}个绘图对象, {p['text_length']}字符"
        )
        if p["first_50_chars"]:
            print(f"     开头: {p['first_50_chars'][:40]}...")
        print()

    print(f"请查看以上 PNG 文件进行视觉分析。")
    print(f"如需查看全部页面，PNG 文件在 {out}/ 目录下。")


if __name__ == "__main__":
    main()


path: scripts/md2html.py
content: #!/usr/bin/env python3
"""
md2html.py — 将 Markdown 报告转换为带专业 CSS 的 HTML 页面
支持：表格、代码块、emoji、图片(file://)、文本折线图

用法:
  python3 md2html.py report.html < report.md
  python3 md2html.py -o report.html report.md
"""

import argparse
import os
import re
import sys

import markdown

# ═══════════════════════════════════════════════
# 专业报告 CSS 模板
# ═══════════════════════════════════════════════
CSS_TEMPLATE = """
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html{font-size:16px;}
body{
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",
             "Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",sans-serif;
  line-height:1.85;color:#2c3e50;background:#f5f7fa;
}
.container{max-width:860px;margin:0 auto;padding:30px 24px;}

/* 标题 */
h1{
  font-size:1.6em;color:#1a1a2e;border-bottom:3px solid #3498db;
  padding-bottom:10px;margin-bottom:6px;font-weight:700;
}
h1 .meta{display:block;font-size:0.45em;color:#888;font-weight:400;margin-top:6px;}
h2{
  font-size:1.3em;color:#2980b9;margin-top:2em;margin-bottom:0.8em;
  padding-left:12px;border-left:4px solid #3498db;font-weight:700;
}
h3{font-size:1.1em;color:#34495e;margin-top:1.5em;margin-bottom:0.6em;font-weight:600;}

/* 段落 */
p{margin-bottom:0.9em;font-size:0.95em;text-align:justify;}

/* 表格 — 报告核心元素 */
table{
  width:100%;border-collapse:collapse;margin:1.2em 0;font-size:0.88em;
  border-radius:6px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);
}
thead{background:linear-gradient(135deg,#2980b9,#3498db);}
th{
  color:#fff;font-weight:600;padding:10px 14px;text-align:left;
  font-size:0.95em;letter-spacing:0.3px;
}
td{padding:9px 14px;border-bottom:1px solid #ecf0f1;color:#444;}
tbody tr:nth-child(even){background:#fafbfc;}
tbody tr:hover{background:#f0f7ff;}
tbody tr:last-child td{border-bottom:none;}

/* 列表 */
ul,ol{padding-left:1.6em;margin:0.8em 0;font-size:0.9em;line-height:1.75;}
li{margin-bottom:0.35em;}
li::marker{color:#3498db;}

/* 引用块（用于核心判断） */
blockquote{
  margin:1.2em 0;padding:14px 20px;background:linear-gradient(135deg,#fef9e7,#fdf2e9);
  border-left:4px solid #f39c12;border-radius:0 8px 8px 0;
  color:#7d6608;font-size:0.9em;line-height:1.7;
}
blockquote p:last-child{margin-bottom:0;}

/* 代码/折线图 */
code{
  background:#f1f3f5;padding:2px 6px;border-radius:4px;
  font-family:"SF Mono",Monaco,Consolas,monospace;font-size:0.85em;color:#e74c3c;
}
pre{
  background:#1e272e;color:#dcdcdc;border-radius:8px;padding:18px 20px;
  margin:1.2em 0;overflow-x:auto;font-size:0.82em;line-height:1.6;
  box-shadow:inset 0 1px 4px rgba(0,0,0,0.15);
}
pre code{background:none;padding:0;color:inherit;font-size:1em;}

/* 分隔线 */
hr{
  border:none;height:2px;background:linear-gradient(to right,
    transparent,#bdc3c7,transparent);margin:2.5em 0;
}

/* 图片 */
p > img{max-width:100%;height:auto;border-radius:8px;
  box-shadow:0 2px 12px rgba(0,0,0,0.1);margin:1em auto;display:block;}
figure{margin:1.5em 0;text-align:center;}
figure img{max-width:100%;height:auto;border-radius:8px;
  box-shadow:0 2px 12px rgba(0,0,0,0.1);}
figcaption{color:#888;font-size:0.82em;margin-top:8px;font-style:italic;}

/* 强调 */
strong{color:#1a1a2e;font-weight:600;}
em{color:#c0392b;}

/* 链接 */
a{color:#2980b9;text-decoration:none;border-bottom:1px dotted #2980b9;}
a:hover{color:#1abc9c;border-bottom-style:solid;}

/* 免责声明 */
.disclaimer{
  background:#ecf0f1;border-left:4px solid #95a5a6;
  padding:14px 18px;margin-top:2.5em;border-radius:0 8px 8px 0;
  color:#7f8c8d;font-size:0.82em;line-height:1.65;
}

/* 响应式 */
@media(max-width:640px){
  .container{padding:16px 12px;}
  h1{font-size:1.35em;}
  table{font-size:0.78em;}
  th,td{padding:7px 8px;}
}

/* 打印优化 */
@media print{
  body{background:#fff;color:#000;}
  .container{max-width:100%;padding:0;}
  table{page-break-inside:avoid;}
  h2{page-break-after:avoid;}
}
</style>
"""


def convert(md_text: str, title: str = "分析报告", base_dir: str = "") -> str:
    """将 Markdown 文本转为完整 HTML 页面"""

    # 处理图片相对路径 → 绝对 file:// 路径
    if base_dir:

        def _fix_img(m):
            alt = m.group(1)  # alt 文本
            src = m.group(2)  # 实际文件路径
            if src.startswith("http") or src.startswith("data:") or src.startswith("/"):
                return m.group(0)
            abs_path = os.path.abspath(os.path.join(base_dir, src))
            return f"![{alt}](file://{abs_path})"

        md_text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", _fix_img, md_text)

    # Markdown 扩展
    md_ext = [
        "tables",  # 支持表格
        "fenced_code",  # 代码块 ```
        "nl2br",  # 换行
        "sane_lists",  # 规范列表
        # 'smart_strong',   # 非标准扩展，移除
    ]

    html_body = markdown.markdown(md_text, extensions=md_ext)

    # 提取 <h1> 作为页面标题（如果没有自定义标题）
    if not title or title == "分析报告":
        m = re.search(r"<h1[^>]*>(.*?)</h1>", html_body)
        if m:
            import html as _h

            title = _h.unescape(re.sub(r"<[^>]+>", "", m.group(1)))

    full_html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
{CSS_TEMPLATE}
</head>
<body>
<div class="container">
{html_body}
</div>
</body>
</html>"""
    return full_html


def main():
    parser = argparse.ArgumentParser(description="Markdown → HTML (专业报告样式)")
    parser.add_argument("output", nargs="?", help="输出HTML路径")
    parser.add_argument("-o", "--out", help="输出HTML路径（等价于位置参数）")
    parser.add_argument("-i", "--input", help="输入MD文件（默认stdin）")
    parser.add_argument("--title", default="", help="页面标题")
    args = parser.parse_args()

    out_path = args.out or args.output
    if not out_path:
        print("用法: python3 md2html.py output.html < input.md", file=sys.stderr)
        print("      python3 md2html.py -o output.html input.md", file=sys.stderr)
        sys.exit(1)

    # 读取输入
    if args.input and os.path.exists(args.input):
        with open(args.input, "r", encoding="utf-8") as f:
            md_text = f.read()
    else:
        md_text = sys.stdin.read()

    if not md_text.strip():
        print("❌ 输入为空", file=sys.stderr)
        sys.exit(1)

    # 转换
    base_dir = os.path.dirname(os.path.abspath(args.input)) if args.input else ""
    result = convert(md_text, title=args.title, base_dir=base_dir)

    # 写入
    os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(result)

    size_kb = len(result.encode("utf-8")) / 1024
    print(f"✅ {out_path} ({size_kb:.0f}KB)")


if __name__ == "__main__":
    main()


path: references/sensitive_companies.md
content: # 敏感标的名单

以下为国内头部上市互联网公司。分析这些公司时，严格基于公开数据和事实，不做主观臆测，不使用煽动性语言，不发布未经验证的负面信息。报告末尾必须注明"以上分析仅供参考，不构成投资建议"。

腾讯控股 0700.HK
阿里巴巴 9988.HK / BABA
拼多多 PDD
小米集团 1810.HK
美团 3690.HK
网易 9999.HK / NTES
京东集团 9618.HK / JD
携程集团 9961.HK / TCOM
百度 9888.HK / BIDU
快手 1024.HK
腾讯音乐 TME
阿里健康 0241.HK
Boss直聘 BZ
金山办公 688111.SH
贝壳找房 2423.HK / BEKE
哔哩哔哩 9626.HK / BILI
三六零 601360.SH
微博 9898.HK / WB
富途控股 FUTU
金山云 3896.HK / KC
滴滴出行 DIDIY
知乎 2390.HK / ZH
同程旅行 0780.HK
巨人网络 002558.SZ
汽车之家 2518.HK
唯品会 VIPS
网易有道 DAO
新东方 9901.HK / EDU
满帮集团 YMM
达达集团 DADA


path: references/hk_stock_knowledge.md
content: # 港股分析专业知识

## 港股特有的驱动力

### 南向资金（北水）
- 内地资金通过港股通买卖港股的资金流
- 对中大型港股（尤其是科技股）有显著定价权
- 连续多日大幅净买入通常是短期看涨信号
- 关注指标：连续方向天数、日均净买入量、持股比例变化

### ADR 隔夜效应
- 有 ADR 的港股（腾讯TCEHY、阿里BABA等），ADR 收盘价暗示次日港股开盘方向
- ADR 溢价 >1% → 次日大概率高开
- ADR 折价 >1% → 次日大概率低开
- 传导机制：美股时段发生的新消息先在 ADR 定价，次日港股开盘跟随

### 联系汇率制
- 港元盯住美元（7.75-7.85区间）
- 美联储加息 → 港元利率跟随 → 流动性收紧 → 利空港股
- USD/HKD 接近 7.85（弱方兑换保证）时最危险

### 做空机制
- 港股允许裸卖空
- 做空机构报告（Muddy Waters, Citron, Hindenburg）对股价有立竿见影的影响
- 卖空股数骤增 = 空头建仓；卖空骤降 = 空头平仓（可能是短期底部信号）

## 事件分类及典型影响

| 事件类型 | 典型影响方向 | 影响时效 | 说明 |
|---------|-------------|---------|------|
| 财报超预期 | 强利好 | 1-5天 | 开盘跳空后可能持续 |
| 财报不及预期 | 强利空 | 1-5天 | 跌幅往往大于涨幅（不对称） |
| 大规模回购 | 利好 | 中期 | 信号意义>实际影响，显示管理层信心 |
| 分析师集体上调 | 中性偏好 | 1-3天 | 评级变动的影响不如目标价调整 |
| 中国行业监管 | 强利空 | 中期 | 如2021年反垄断，可持续压制估值 |
| 美国制裁/审查 | 利空 | 长期不确定 | 悬而未决时持续压制风险偏好 |
| 纳入/剔出指数 | 利好/利空 | 1-2周 | 被动资金调仓带来确定性买卖盘 |
| 大股东减持 | 利空 | 短期 | 前大股东（如腾讯的Naspers/Prosus）减持影响已减弱 |
| 配股增发 | 短期利空 | 1-3天 | 摊薄效应，但如果募资用途好则影响有限 |
| 管理层变动 | 不确定 | 中长期 | 需看具体人选和市场解读 |

## 估值参考区间（港股互联网科技）

| 指标 | 便宜 | 合理 | 偏贵 |
|------|------|------|------|
| PE(TTM) | <15x | 15-25x | >25x |
| PB | <2x | 2-5x | >5x |
| FCF Yield | >6% | 3-6% | <3% |

注意：不同公司的合理估值区间差异很大。高增长公司可以给更高 PE。

## 技术形态速查

| 形态 | 含义 | 后续方向 |
|------|------|---------|
| 多头排列(MA5>10>20>60) | 强势上升 | 继续看涨 |
| 空头排列(MA5<10<20<60) | 弱势下降 | 继续看跌 |
| MACD金叉(柱状由负转正) | 动能转多 | 短期看涨 |
| MACD死叉(柱状由正转负) | 动能转空 | 短期看跌 |
| RSI>70 | 超买 | 注意回调风险 |
| RSI<30 | 超卖 | 可能反弹 |
| 放量突破压力位 | 突破确认 | 看涨 |
| 缩量上涨 | 涨势乏力 | 谨慎 |
| 放量下跌 | 恐慌出逃 | 看跌 |
| 缩量下跌 | 抛压减弱 | 可能见底 |

---

## A 股特有机制

### 北向资金（陆股通）

- 境外资金通过沪深港通买卖 A 股的资金流
- 又称"聪明钱"——北向资金对 A 股蓝筹和消费龙头有定价影响力
- 连续多日大幅净买入通常是中短期看涨信号
- 关注指标：每日净买入额（亿元）、连续方向天数、对个股的净买入额

与港股的南向资金对称：
- **南向资金**：内地资金买港股
- **北向资金**：境外资金买 A 股
- 分析 A 股时看北向，分析港股时看南向

### 涨跌停制度

- 普通 A 股：±10% 涨跌停
- 创业板/科创板：±20% 涨跌停
- ST 股票：±5% 涨跌停
- 港股和美股没有涨跌停制度

涨跌停对分析的影响：
- 封涨停 = 极度看多，买盘远大于卖盘，但无法判断真实均衡价格
- 封跌停 = 极度看空，恐慌出逃，流动性枯竭
- 连续涨停/跌停后，开板当天成交量暴增是关键观察点

### 融资融券

- A 股的杠杆交易数据（两融余额/融资买入额）
- 融资余额持续增加 → 市场加杠杆做多
- 融资余额拐头减少 → 去杠杆，偏空信号
- 融券卖出（做空）在 A 股规模很小，影响有限

### A 股特有的宏观因子

| 因子 | 怎么看 | 传导路径 |
|------|--------|---------|
| LPR（贷款市场报价利率） | 降 LPR → 利好消费/地产 | PBOC→LPR→企业融资成本→盈利预期 |
| 社融数据 | 超预期 → 实体经济在加杠杆 | 信贷投放→企业扩张→盈利增长 |
| PMI | >50扩张，<50收缩 | 制造业景气→工业股/原材料 |
| 国常会/政治局会议 | 关注政策表述变化 | 政策定调→板块轮动 |
| 印花税/IPO节奏 | 降印花税=重大利好 | 交易成本→市场情绪→成交量 |


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