Skill: mli-cj/finance-research-report (version 1.0.0)

--- name: finance-research-report description: "A股每周金融投研报告生成器 — 基于 AKShare / 新浪财经 / 东方财富数据源，自动生成包含技术分析、交易信号、风险评估的专业 PDF 研报。Triggers: '生成周报', '金融报告', '投研报告', '股票分析', 'A股研报'。" user-invocable: true argument-hint: "股票代码(逗号分隔) [--date YYYY-MM-DD] [--author 名字]" metadata: {"clawdbot":{"emoji":"📊","requires":{"bins":["python3"]},"os":["darwin","linux"],"files":["scripts/generate_weekly_report.py","scripts/data_fetcher.py","scripts/technical_analysis.py","scripts/chart_generator.py","scripts/report_generator.py"]}} --- # 每周金融投研报告生成器 生成专业券商研报风格的 A 股投研 PDF 报告，包含技术分析、交易信号、风险评估。 ## Quick Start **生成报告（默认跳过全市场统计，更快）：** ```bash pip3 install -r {baseDir}/requirements.txt python3 {baseDir}/scripts/generate_weekly_report.py --stocks 000001,600519,000858 --skip-breadth ``` **完整报告（含全市场涨跌统计，约多1分钟）：** ```bash python3 {baseDir}/scripts/generate_weekly_report.py --stocks 000001,600519,000858 ``` **指定日期和作者：** ```bash python3 {baseDir}/scripts/generate_weekly_report.py --stocks 600519 --date 2026-03-14 --author 投研团队 ``` --- ## How It Works `generate_weekly_report.py` 脚本自动完成以下步骤： 1. **获取主要指数数据** — 上证指数、深证成指、沪深300、创业板指 2. **获取市场情绪** — 全市场涨跌家数、涨停跌停统计（可选） 3. **获取北向资金** — 本周净流入/净流出 4. **个股技术分析** — 均线、RSI、MACD、KDJ、ATR、量价等 5. **获取持仓新闻** — 个股相关新闻 + 全球宏观要闻 6. **生成 PDF 报告** — HTML+CSS → weasyprint → Ghostscript 优化 ### Analysis Framework 技术指标体系参考 `{baseDir}/scripts/technical_analysis.py`： ``` 标的: 贵州茅台 (600519) 收盘价: ¥1,856.00 (+2.3%) 趋势: 多头排列 (MA5 > MA10 > MA20) 技术指标: RSI(14): 62.5 — 正常区间 MACD: 金叉，柱状翻红 KDJ: K=68, D=55, J=94 交易信号: 🟢 买入信号 x3 | 🔴 卖出信号 x0 信号强度: 强 风险评估: 风险评分: 4/10 止损位: ¥1,812.00 止盈位: ¥1,920.00 建议仓位: 60% ``` --- ## 报告内容结构 生成的 PDF 报告（A4 页面）包含以下章节： 1. **封面与投资要点** — 核心观点、看多/看空/中性统计、数据来源 2. **目录** 3. **市场概览** — 大盘指数、涨跌家数、北向资金、持仓资讯、宏观要闻 4. **个股技术分析** — 含价格走势图、MACD+RSI 图表 5. **交易信号汇总** — 买入/卖出信号、信号强度 6. **风险评估与仓位建议** — 风险评分、止损止盈、仓位系数 7. **风险提示与免责声明** --- ## 技术指标体系 | 类别 | 指标 | 说明 | |------|------|------| | 均线 | MA5, MA10, MA20 | 趋势与排列判断 | | 动量 | RSI(14) | 相对强弱，超买>80 / 超卖<20 | | 动量 | MACD(12,26,9) | 趋势强度，金叉/死叉 | | 动量 | KDJ(9,3,3) | 超买超卖判断 | | 波动率 | ATR(14) | 真实波动幅度 | | 波动率 | 标准差(20日) | 价格离散度 | | 量价 | 量比(5日) | 成交活跃度 | | 量价 | 换手率 | 交易活跃程度 | ### 交易信号 - **买入信号**：MA5 金叉 MA10、价格突破 MA10、量价齐升、RSI 50-70、MACD 翻红 - **卖出信号**：MA5 死叉 MA10、放量跌破 MA5、RSI>80、5日涨幅>15%、KDJ 超买 ### 风险控制 - 止损位：min(前日低点×0.995, MA10×0.99, 入场价×0.95) - 止盈位：当前价 + ATR × 2 - 仓位系数：(10 - 风险评分) / 10 --- ## 数据来源 数据通过 AKShare 开源接口获取： | 数据类型 | 来源 | |----------|------| | A 股日线行情（前复权） | 新浪财经 | | 主要指数日线数据 | 新浪财经 | | 个股实时行情与名称 | 新浪财经 | | 全市场涨跌统计 | 新浪财经 | | 北向资金数据 | 东方财富 | | 个股相关新闻 | 东方财富 | | 全球财经要闻 | 东方财富 | --- ## 系统依赖 - `python3` on PATH - `pango`（macOS: `brew install pango`）— weasyprint 渲染依赖 - `ghostscript`（macOS: `brew install ghostscript`）— PDF 兼容性优化 - Python 包：`pip3 install -r {baseDir}/requirements.txt` --- ## Output Format Rules - 输出为 PDF 文件，保存在 `output/` 目录 - 文件名格式：`周报_YYYY-MM-DD.pdf` - 样式参考券商研报（红色主题、专业表格、技术图表） - 每份报告末尾附风险提示与免责声明 --- ## Limitations & Notes - 数据通过公开接口获取，部分数据可能有 15 分钟延迟 - 行业板块资金流向受网络环境限制可能不可用 - 本技能不执行交易操作，所有建议仅供参考 - 全市场涨跌统计（`--skip-breadth` 可跳过）约需 1 分钟 - 需要系统安装 pango 和 ghostscript --- ## Security & Privacy - **无需 API Key**：所有数据通过 AKShare 公开接口获取 - **本地生成**：PDF 报告在本地生成，不上传至任何服务器 - **仅供参考**：交易信号为技术分析结果，不构成投资建议 *免责声明：本报告基于公开市场数据的技术分析，不构成任何投资建议。投资有风险，入市需谨慎。*

path: CLAUDE.md
content: # finance-research-report

## 项目简介
A股每周金融投研报告自动生成工具（OpenClaw / ClawHub 技能）。输出专业 PDF 研报，参考券商研报格式，集成日频短期技术分析框架。

## 项目结构
```
finance-research-report/
├── SKILL.md               # OpenClaw 技能定义（主入口）
├── README.md              # 技能说明文档
├── _meta.json             # ClawHub 发布元数据
├── CLAUDE.md              # 项目说明
├── requirements.txt       # Python 依赖
├── sample.pdf             # 参考研报模板（西南证券）
├── scripts/
│   ├── generate_weekly_report.py  # 主入口脚本
│   ├── data_fetcher.py            # 数据获取模块（AKShare / 新浪 / 东方财富）
│   ├── technical_analysis.py      # 技术分析模块
│   ├── chart_generator.py         # matplotlib 图表生成模块
│   └── report_generator.py        # PDF 报告生成模块（HTML+CSS → weasyprint）
└── output/                # 生成的 PDF 报告输出目录
```

## 快速使用

### 命令行方式
```bash
cd scripts && python3 generate_weekly_report.py --stocks 000001,600519,000858
```

### OpenClaw 技能
```
/finance-research-report 000001,600519,000858
```

## 依赖
- Python 3.10+
- akshare, pandas, numpy, weasyprint, matplotlib
- 系统依赖：pango (`brew install pango`), ghostscript (`brew install ghostscript`)

## 输出格式
PDF（A4 页面），样式参考西南证券研报，包含：
- 封面（投资要点、信号统计）
- 目录
- 市场概览（指数、情绪、北向资金、宏观要闻）
- 个股技术分析卡片（含图表）
- 交易信号汇总
- 风险评估与仓位建议
- 风险提示与免责声明

## 技术指标体系
- 均线：MA5/MA10/MA20 + 排列判断
- 动量：RSI(14), MACD(12,26,9), KDJ(9,3,3)
- 波动率：ATR(14), 标准差(20日)
- 量价：量比(5日), 换手率
- 信号：金叉/死叉、量价配合、超买超卖
- 风控：止损止盈、风险评分、仓位系数


path: README.md
content: # finance-research-report

> OpenClaw skill — A股每周金融投研报告生成器，自动生成专业券商研报风格的 PDF 报告。

## What it does

- 生成专业券商研报风格的 **PDF 报告**（A4 页面）
- 多标的技术分析：均线、RSI、MACD、KDJ、ATR 等指标
- 交易信号判断：买入/卖出信号、信号强度评估
- 风险评估与仓位建议：止损止盈、风险评分、仓位系数
- 数据可视化：价格走势图、MACD+RSI 图表、指数对比图
- 市场概览：大盘指数、北向资金、涨跌家数、持仓新闻

## Installation

```bash
clawhub install finance-research-report
```

Or search and install from within OpenClaw:

```bash
clawhub search finance-research-report
```

Once installed, use `/finance-research-report` in any OpenClaw session.

### System dependencies (macOS)

```bash
brew install pango ghostscript
```

## Quick start

```
/finance-research-report 000001,600519,000858
/finance-research-report 600519 --date 2026-03-14
/finance-research-report 000858,000333,601318 --author 投研团队
```

## Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| 股票代码 | Yes | 逗号分隔的 A 股代码，如 `000001,600519` |
| `--date` | No | 报告日期 (YYYY-MM-DD)，默认今天 |
| `--author` | No | 作者名称，默认 "AI投研助手" |
| `--skip-breadth` | No | 跳过全市场涨跌统计（更快） |

## Requirements

- `python3` on PATH
- `pango` — weasyprint PDF 渲染依赖
- `ghostscript` — PDF 兼容性优化（macOS Preview）
- No API keys needed — data fetched via AKShare public APIs

## Data sources

| Data | Source |
|------|--------|
| A 股日线行情、指数、实时行情 | 新浪财经 |
| 北向资金、个股新闻、宏观要闻 | 东方财富 |

## Report structure

1. Cover page with key findings & signal summary
2. Table of contents
3. Market overview (indices, breadth, northbound capital, news)
4. Individual stock technical analysis (with charts)
5. Trading signal summary
6. Risk assessment & position sizing
7. Risk disclaimer

## Disclaimer

This skill provides informational analysis only and does not constitute financial advice. Data sourced from AKShare / Sina Finance / Eastmoney.


path: _meta.json
content: slug: finance-research-report
version: 1.0.0

path: output/周报_2026-03-17.md
content: ---

# 每周金融投研报告

**报告周期**：2026-03-16 至 2026-03-20

**发布日期**：2026-03-17

**分析师**：AI投研助手

**数据来源**：AKShare

---

## 投资要点

- **市场信号概览**：本周跟踪3只标的，其中看多3只，看空0只，中性0只。

- **贵州茅台(600519)**：看多，信号强度中，风险评分5.0/10，建议仓位系数50%
- **五 粮 液(000858)**：看多，信号强度中，风险评分5.0/10，建议仓位系数50%
- **平安银行(000001)**：看多，信号强度中，风险评分5.0/10，建议仓位系数50%

- **风险提示**：市场波动风险、政策变化风险、流动性风险、模型误差风险。本报告仅供参考，不构成投资建议。

---

## 目 录

1. [市场概览](#1-市场概览)
   - 1.1 主要指数表现
   - 1.2 市场情绪指标
   - 1.3 北向资金动向
   - 1.4 宏观要闻
2. [个股技术分析](#2-个股技术分析)
3. [行业板块资金流向](#3-行业板块资金流向)
4. [交易信号汇总](#4-交易信号汇总)
5. [风险评估与仓位建议](#5-风险评估与仓位建议)
6. [风险提示](#6-风险提示)

---

## 1 市场概览

### 1.1 主要指数表现

| 指数 | 收盘价 | 涨跌幅 | 最高 | 最低 | 成交量 |
|------|--------|--------|------|------|--------|
| 上证指数 | 4084.79 | -0.26% | 4096.13 | 4048.09 | 732.23亿 |
| 深证成指 | 14307.58 | 0.19% | 14320.10 | 14097.82 | 753.02亿 |
| 沪深300 | 4671.56 | 0.05% | 4673.69 | 4621.81 | 296.22亿 |
| 创业板指 | 3357.02 | 1.41% | 3360.35 | 3284.05 | 208.22亿 |

### 1.2 市场情绪指标


| 指标 | 数值 |
|------|------|
| 上涨家数 | 866 |
| 下跌家数 | 4541 |
| 平盘家数 | 81 |
| 涨停家数 | 43 |
| 跌停家数 | 26 |
| 总家数 | 5488 |

### 1.3 北向资金动向

本周北向资金净流入合计：**0.00亿元**

### 1.4 宏观要闻

- 【俄米格31携高超音速导弹飞越日本海】据国际文传电讯社今天（3月17日）援引俄罗斯国防部消息报道，俄军米格-31战机携带“匕首”高超音速导弹，在日本海中立水域上空执行了一次计划内飞行。
- 【伊朗媒体称拉里贾尼将发表声明】伊朗官方媒体最新报道称，伊朗最高国家安全委员会秘书阿里·拉里贾尼“将很快发表一份声明”。据以色列媒体稍早前报道，有以色列官员称，当地时间3月16日夜间至17日凌晨，以军对伊朗境内发动空袭，目标正是拉里贾尼。据称以方目前“还不清楚拉里贾尼是否在袭击中死亡或受伤”。
- 【欧盟呼吁结束对伊军事行动】总台记者获悉，当地时间17日，欧盟外交与安全政策高级代表卡拉斯表示，当前伊朗冲突对全球经济造成严重影响，各方应尽快推动局势降温，通过外交途径结束冲突。
- 据报道，日本与美国将在本周峰会上就联合开发稀土、锂和铜达成一致。
- 【北京“非共识”项目支持 超快存储材料迎新突破】作为北京市支持的首批具有颠覆性创新的首批非共识性科研项目之一，交错磁体项目已取得核心技术突破，为开发兼具超高密度、超快读写和低功耗特性的新一代磁存储器奠定关键科学基础，目前正在加紧推进应用研究。
- LME铜库存大增18,775吨，创2019年以来新高。
- 据报道，特朗普政府官员考虑达成新的10亿美元协议以阻止近海风电场项目。
- 【莎普爱思：拟5.28亿元收购上海勤礼100%股权】莎普爱思(603168.SH)公告称，公司拟以现金5.28亿元收购控股股东养和实业及一致行动人谊和医疗持有的上海勤礼100%股权。本次交易完成后，公司将间接持有上海天伦医院100%股权。交易旨在解决与实际控制人间的同业竞争问题，并增强公司医疗服务板块的盈利能力。

---

## 2 个股技术分析

### 2.1 贵州茅台（600519）

**最新价**：1485.00元 | **日涨跌**：+1.70% | **5日涨跌**：+5.93% | **趋势判断**：看多

#### 价格与均线

| 指标 | 数值 | 含义 |
|------|------|------|
| MA5 | 1430.16 | 5日均线 |
| MA10 | 1415.19 | 10日均线 |
| MA20 | 1443.95 | 20日均线 |
| 均线排列 | 震荡整理 | 方向不明 |

#### 成交量分析

| 指标 | 数值 | 含义 |
|------|------|------|
| 量比 | 1.45 | 正常 |
| 换手率 | 0.39% | 正常 |
| 量能趋势 | 平稳 | 近期成交量变化方向 |

#### 波动率

| 指标 | 数值 |
|------|------|
| ATR(14) | 24.41 |
| 标准差(20) | 40.85 |
| 日内振幅 | 2.48% |

#### 动量指标

| 指标 | 数值 | 状态 |
|------|------|------|
| RSI(14) | 61.3 | 正常 |
| MACD | -0.7620 | 多头 |
| MACD柱 | 2.0267 | 红柱 |
| KDJ-K | 60.6 | - |
| KDJ-D | 37.7 | - |
| KDJ-J | 106.4 | 超买 |### 2.2 五 粮 液（000858）

**最新价**：104.79元 | **日涨跌**：+0.24% | **5日涨跌**：+2.68% | **趋势判断**：看多

#### 价格与均线

| 指标 | 数值 | 含义 |
|------|------|------|
| MA5 | 103.35 | 5日均线 |
| MA10 | 102.52 | 10日均线 |
| MA20 | 103.62 | 20日均线 |
| 均线排列 | 震荡整理 | 方向不明 |

#### 成交量分析

| 指标 | 数值 | 含义 |
|------|------|------|
| 量比 | 1.28 | 正常 |
| 换手率 | 0.46% | 正常 |
| 量能趋势 | 平稳 | 近期成交量变化方向 |

#### 波动率

| 指标 | 数值 |
|------|------|
| ATR(14) | 1.55 |
| 标准差(20) | 1.68 |
| 日内振幅 | 1.74% |

#### 动量指标

| 指标 | 数值 | 状态 |
|------|------|------|
| RSI(14) | 52.9 | 正常 |
| MACD | -0.6721 | 多头 |
| MACD柱 | 0.2884 | 红柱 |
| KDJ-K | 62.3 | - |
| KDJ-D | 45.2 | - |
| KDJ-J | 96.6 | 正常 |### 2.3 平安银行（000001）

**最新价**：11.03元 | **日涨跌**：+1.01% | **5日涨跌**：+2.04% | **趋势判断**：看多

#### 价格与均线

| 指标 | 数值 | 含义 |
|------|------|------|
| MA5 | 10.94 | 5日均线 |
| MA10 | 10.86 | 10日均线 |
| MA20 | 10.89 | 20日均线 |
| 均线排列 | 震荡整理 | 方向不明 |

#### 成交量分析

| 指标 | 数值 | 含义 |
|------|------|------|
| 量比 | 1.41 | 正常 |
| 换手率 | 0.55% | 正常 |
| 量能趋势 | 平稳 | 近期成交量变化方向 |

#### 波动率

| 指标 | 数值 |
|------|------|
| ATR(14) | 0.12 |
| 标准差(20) | 0.09 |
| 日内振幅 | 1.54% |

#### 动量指标

| 指标 | 数值 | 状态 |
|------|------|------|
| RSI(14) | 55.8 | 正常 |
| MACD | -0.0364 | 多头 |
| MACD柱 | 0.0326 | 红柱 |
| KDJ-K | 78.0 | - |
| KDJ-D | 67.6 | - |
| KDJ-J | 98.9 | 正常 |

---

## 3 行业板块资金流向

暂无数据

---

## 4 交易信号汇总

### 贵州茅台（600519）—— 看多

| 类型 | 信号详情 |
|------|---------|
| 买入信号 | 量价齐升（量比1.45）、RSI动量良好（61.3）、MACD柱状线翻红 |
| 卖出信号 | KDJ超买（J=106.4） |
| 信号强度 | 中 |
| 综合判断 | 看多 | 信号强度: 中 | 风险评分: 5.0/10 |
### 五 粮 液（000858）—— 看多

| 类型 | 信号详情 |
|------|---------|
| 买入信号 | 量价齐升（量比1.28）、RSI动量良好（52.9） |
| 卖出信号 | 无 |
| 信号强度 | 中 |
| 综合判断 | 看多 | 信号强度: 中 | 风险评分: 5.0/10 |
### 平安银行（000001）—— 看多

| 类型 | 信号详情 |
|------|---------|
| 买入信号 | 量价齐升（量比1.41）、RSI动量良好（55.8） |
| 卖出信号 | 无 |
| 信号强度 | 中 |
| 综合判断 | 看多 | 信号强度: 中 | 风险评分: 5.0/10 |


---

## 5 风险评估与仓位建议

### 5.1 风险评分与仓位

| 标的 | 风险评分(0-10) | 建议仓位系数 | 止损位 | 止盈位 |
|------|----------------|-------------|--------|--------|
| 贵州茅台(600519) | 5.0 | 50% | 1401.04 | 1533.83 |
| 五 粮 液(000858) | 5.0 | 50% | 99.55 | 107.88 |
| 平安银行(000001) | 5.0 | 50% | 10.48 | 11.27 |

### 5.2 止损止盈说明

- **止损位**：取以下三者最小值：前日低点-0.5%、MA10下方1%、入场价-5%
- **止盈位**：当前价格 + ATR(14) x 2
- **仓位系数**：(10 - 风险评分) / 10，系数越高建议仓位越重

---

## 6 风险提示

- **市场系统性风险**：宏观经济下行、地缘政治冲突等因素可能导致市场整体下跌。
- **政策风险**：监管政策变化可能对特定行业或个股产生重大影响。
- **流动性风险**：部分标的流动性不足，可能导致无法及时止损。
- **模型风险**：技术分析基于历史数据，过往表现不代表未来走势。
- **数据风险**：数据来源为公开市场数据，可能存在延迟或误差。

---

## 重要声明

本报告由 **AI投研助手** 基于公开市场数据自动生成，数据来源为 AKShare 开源金融数据接口。

本报告仅供参考和学习交流使用，**不构成任何投资建议**。投资者应结合自身投资目标和风险承受能力自行判断，
本报告作者不对使用本报告内容而造成的任何损失承担责任。

**技术指标体系说明**：
- 均线系统：MA5/MA10/MA20
- 动量指标：RSI(14)、MACD(12,26,9)、KDJ(9,3,3)
- 波动率指标：ATR(14)、标准差(20日)
- 量价指标：量比(5日)、换手率

---

*报告生成时间：2026-03-17 17:21:51*

*Powered by AKShare & Claude AI*

path: requirements.txt
content: akshare>=1.10.0
pandas>=1.5.0
numpy>=1.23.0
weasyprint>=60.0
matplotlib>=3.7.0


path: scripts/chart_generator.py
content: """
图表生成模块 - 使用 matplotlib 生成 K线/均线、指标、对比柱状图
所有图表返回 base64 编码的 PNG，可直接嵌入 HTML <img> 标签。
"""
import io
import base64
import pandas as pd
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import rcParams

# 中文字体
rcParams["font.sans-serif"] = ["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimHei", "sans-serif"]
rcParams["axes.unicode_minus"] = False


def _fig_to_base64(fig, dpi=150) -> str:
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.15)
    plt.close(fig)
    buf.seek(0)
    return base64.b64encode(buf.read()).decode("utf-8")


def chart_stock_price_ma(df: pd.DataFrame, name: str, symbol: str) -> str:
    """生成价格+均线+成交量图（上下双轴）"""
    df = df.tail(40).copy()
    df["ma5"] = df["close"].rolling(5).mean()
    df["ma10"] = df["close"].rolling(10).mean()
    df["ma20"] = df["close"].rolling(20).mean()

    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7.5, 3.5), gridspec_kw={"height_ratios": [2.5, 1]}, sharex=True)
    fig.subplots_adjust(hspace=0.08)

    x = range(len(df))
    dates = df["date"].dt.strftime("%m-%d")

    # 价格
    ax1.plot(x, df["close"], color="#333", linewidth=1.2, label="收盘价")
    ax1.plot(x, df["ma5"], color="#e6a817", linewidth=0.8, label="MA5", alpha=0.9)
    ax1.plot(x, df["ma10"], color="#1e88e5", linewidth=0.8, label="MA10", alpha=0.9)
    ax1.plot(x, df["ma20"], color="#d81b60", linewidth=0.8, label="MA20", alpha=0.9)
    ax1.fill_between(x, df["close"].min() * 0.998, df["close"], alpha=0.05, color="#c00")
    ax1.legend(loc="upper left", fontsize=7, ncol=4, framealpha=0.7)
    ax1.set_title(f"{name}（{symbol}）近40日走势", fontsize=10, fontweight="bold")
    ax1.set_ylabel("价格（元）", fontsize=8)
    ax1.tick_params(labelsize=7)
    ax1.grid(axis="y", alpha=0.3)

    # 成交量
    colors = ["#c00" if df["close"].iloc[i] >= df["open"].iloc[i] else "#00a86b" for i in range(len(df))]
    ax2.bar(x, df["volume"] / 1e4, color=colors, alpha=0.7, width=0.6)
    ax2.set_ylabel("成交量(万)", fontsize=7)
    ax2.tick_params(labelsize=7)
    ax2.grid(axis="y", alpha=0.3)

    # X 轴标签
    step = max(1, len(df) // 8)
    ax2.set_xticks(range(0, len(df), step))
    ax2.set_xticklabels([dates.iloc[i] for i in range(0, len(df), step)], fontsize=7, rotation=30)

    return _fig_to_base64(fig)


def chart_macd_rsi(df: pd.DataFrame, name: str, symbol: str) -> str:
    """生成 MACD + RSI 双指标图"""
    df = df.tail(40).copy()

    # MACD
    ema12 = df["close"].ewm(span=12, adjust=False).mean()
    ema26 = df["close"].ewm(span=26, adjust=False).mean()
    macd = ema12 - ema26
    signal = macd.ewm(span=9, adjust=False).mean()
    hist = macd - signal

    # RSI
    delta = df["close"].diff()
    gain = delta.where(delta > 0, 0.0).rolling(14).mean()
    loss = (-delta).where(delta < 0, 0.0).rolling(14).mean()
    rs = gain / loss
    rsi = 100 - (100 / (1 + rs))

    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7.5, 3), gridspec_kw={"height_ratios": [1, 1]}, sharex=True)
    fig.subplots_adjust(hspace=0.15)
    x = range(len(df))
    dates = df["date"].dt.strftime("%m-%d")

    # MACD
    bar_colors = ["#c00" if h >= 0 else "#00a86b" for h in hist]
    ax1.bar(x, hist, color=bar_colors, alpha=0.6, width=0.6)
    ax1.plot(x, macd, color="#1e88e5", linewidth=0.9, label="MACD")
    ax1.plot(x, signal, color="#e6a817", linewidth=0.9, label="Signal")
    ax1.axhline(y=0, color="#999", linewidth=0.5, linestyle="--")
    ax1.legend(fontsize=7, loc="upper left", framealpha=0.7)
    ax1.set_title(f"{name} MACD & RSI", fontsize=9, fontweight="bold")
    ax1.set_ylabel("MACD", fontsize=7)
    ax1.tick_params(labelsize=7)
    ax1.grid(axis="y", alpha=0.3)

    # RSI
    ax2.plot(x, rsi, color="#7b1fa2", linewidth=1)
    ax2.axhline(y=70, color="#c00", linewidth=0.6, linestyle="--", alpha=0.7)
    ax2.axhline(y=30, color="#00a86b", linewidth=0.6, linestyle="--", alpha=0.7)
    ax2.axhline(y=50, color="#999", linewidth=0.4, linestyle=":")
    ax2.fill_between(x, 70, 100, alpha=0.05, color="#c00")
    ax2.fill_between(x, 0, 30, alpha=0.05, color="#00a86b")
    ax2.set_ylabel("RSI(14)", fontsize=7)
    ax2.set_ylim(10, 90)
    ax2.tick_params(labelsize=7)
    ax2.grid(axis="y", alpha=0.3)

    step = max(1, len(df) // 8)
    ax2.set_xticks(range(0, len(df), step))
    ax2.set_xticklabels([dates.iloc[i] for i in range(0, len(df), step)], fontsize=7, rotation=30)

    return _fig_to_base64(fig)


def chart_index_comparison(index_data: dict) -> str:
    """生成主要指数涨跌幅对比柱状图"""
    names = []
    pcts = []
    for name, data in index_data.items():
        if data and "pct_change" in data:
            names.append(name)
            pcts.append(float(data["pct_change"]))

    if not names:
        return ""

    fig, ax = plt.subplots(figsize=(7.5, 2.2))
    colors = ["#c00" if p >= 0 else "#00a86b" for p in pcts]
    bars = ax.barh(names, pcts, color=colors, height=0.5, alpha=0.8)

    for bar, pct in zip(bars, pcts):
        ax.text(bar.get_width() + 0.02 * (1 if pct >= 0 else -1), bar.get_y() + bar.get_height() / 2,
                f"{pct:+.2f}%", va="center", fontsize=8, color="#333",
                ha="left" if pct >= 0 else "right")

    ax.axvline(x=0, color="#999", linewidth=0.5)
    ax.set_title("主要指数日涨跌幅", fontsize=10, fontweight="bold")
    ax.set_xlabel("涨跌幅 (%)", fontsize=8)
    ax.tick_params(labelsize=8)
    ax.grid(axis="x", alpha=0.3)
    ax.invert_yaxis()

    return _fig_to_base64(fig)


def chart_signal_summary(signals) -> str:
    """生成信号强度雷达图 / 风险仪表盘"""
    if not signals:
        return ""

    names = [s.name for s in signals]
    buy_counts = [len(s.buy_signals) for s in signals]
    sell_counts = [len(s.sell_signals) for s in signals]
    risk_scores = [s.risk_score for s in signals]

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7.5, 2.5))

    # 左：买卖信号对比
    x = np.arange(len(names))
    w = 0.3
    ax1.bar(x - w / 2, buy_counts, w, color="#c00", alpha=0.7, label="买入信号")
    ax1.bar(x + w / 2, sell_counts, w, color="#00a86b", alpha=0.7, label="卖出信号")
    ax1.set_xticks(x)
    ax1.set_xticklabels(names, fontsize=8)
    ax1.set_ylabel("信号数", fontsize=8)
    ax1.set_title("买卖信号对比", fontsize=9, fontweight="bold")
    ax1.legend(fontsize=7)
    ax1.tick_params(labelsize=7)
    ax1.grid(axis="y", alpha=0.3)

    # 右：风险评分
    colors = ["#00a86b" if r <= 4 else "#e6a817" if r <= 6 else "#c00" for r in risk_scores]
    bars = ax2.barh(names, risk_scores, color=colors, height=0.4, alpha=0.8)
    ax2.set_xlim(0, 10)
    ax2.axvline(x=5, color="#999", linewidth=0.5, linestyle="--")
    for bar, score in zip(bars, risk_scores):
        ax2.text(bar.get_width() + 0.15, bar.get_y() + bar.get_height() / 2,
                 f"{score:.1f}", va="center", fontsize=8)
    ax2.set_title("风险评分 (0-10)", fontsize=9, fontweight="bold")
    ax2.tick_params(labelsize=8)
    ax2.grid(axis="x", alpha=0.3)
    ax2.invert_yaxis()

    fig.tight_layout()
    return _fig_to_base64(fig)


path: scripts/data_fetcher.py
content: """
数据获取模块 - 使用 akshare 获取金融市场数据
优先使用新浪源 API（稳定可靠），东方财富 push2 系列在部分网络环境下不可用。
"""
import akshare as ak
import pandas as pd
from datetime import datetime, timedelta
from typing import Optional


def get_week_range(date: Optional[str] = None) -> tuple[str, str]:
    """获取指定日期所在周的起止日期 (周一到周五)"""
    if date:
        dt = datetime.strptime(date, "%Y-%m-%d")
    else:
        dt = datetime.now()
    monday = dt - timedelta(days=dt.weekday())
    friday = monday + timedelta(days=4)
    return monday.strftime("%Y%m%d"), friday.strftime("%Y%m%d")


def _sina_symbol(symbol: str) -> str:
    """将纯数字代码转换为新浪格式 (sh600519 / sz000001)"""
    if symbol.startswith(("sh", "sz")):
        return symbol
    if symbol.startswith(("6", "9")):
        return f"sh{symbol}"
    return f"sz{symbol}"


def fetch_stock_daily(symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
    """获取个股日线数据（新浪源，前复权）"""
    sina_sym = _sina_symbol(symbol)
    df = ak.stock_zh_a_daily(symbol=sina_sym, start_date=start_date, end_date=end_date, adjust="qfq")
    df = df.reset_index(drop=True) if "date" in df.columns else df.reset_index()
    # 统一列名
    col_map = {
        "date": "date", "open": "open", "close": "close", "high": "high",
        "low": "low", "volume": "volume", "outstanding_share": "outstanding_share",
        "turnover": "turnover_rate",
    }
    df = df.rename(columns={c: col_map.get(c, c) for c in df.columns})
    df["date"] = pd.to_datetime(df["date"])
    df = df.sort_values("date").reset_index(drop=True)

    # 计算换手率（如果缺失）
    if "turnover_rate" not in df.columns or df["turnover_rate"].isna().all():
        if "outstanding_share" in df.columns:
            df["turnover_rate"] = df["volume"] / df["outstanding_share"] * 100
        else:
            df["turnover_rate"] = 0.0
    else:
        df["turnover_rate"] = df["turnover_rate"] * 100  # 转为百分比

    return df


def fetch_index_daily(symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
    """获取指数日线数据（新浪源）
    symbol: 000001(上证), 399001(深证), 000300(沪深300), 399006(创业板)
    """
    sina_sym = _sina_symbol(symbol)
    df = ak.stock_zh_index_daily(symbol=sina_sym)
    df = df.reset_index(drop=True) if "date" in df.columns else df.reset_index()
    df["date"] = pd.to_datetime(df["date"])
    mask = (df["date"] >= pd.to_datetime(start_date)) & (df["date"] <= pd.to_datetime(end_date))
    df = df.loc[mask].sort_values("date").reset_index(drop=True)
    return df


def fetch_stock_name_from_spot(symbol: str) -> str:
    """从新浪实时行情获取股票名称（轻量级，只查单只）"""
    try:
        import urllib.request
        import json
        sina_sym = _sina_symbol(symbol)
        # 使用新浪行情接口获取名称
        url = f"https://hq.sinajs.cn/list={sina_sym}"
        req = urllib.request.Request(url, headers={
            "Referer": "https://finance.sina.com.cn/",
            "User-Agent": "Mozilla/5.0",
        })
        resp = urllib.request.urlopen(req, timeout=10)
        text = resp.read().decode("gbk")
        # 格式: var hq_str_sh600519="贵州茅台,..."
        parts = text.split('"')[1].split(",")
        return parts[0] if parts[0] else symbol
    except Exception:
        return symbol


def fetch_market_breadth() -> dict:
    """获取市场涨跌家数统计（新浪源）"""
    try:
        df = ak.stock_zh_a_spot()
        up = (df["涨跌幅"] > 0).sum()
        down = (df["涨跌幅"] < 0).sum()
        flat = (df["涨跌幅"] == 0).sum()
        limit_up = (df["涨跌幅"] >= 9.9).sum()
        limit_down = (df["涨跌幅"] <= -9.9).sum()
        return {
            "上涨家数": int(up),
            "下跌家数": int(down),
            "平盘家数": int(flat),
            "涨停家数": int(limit_up),
            "跌停家数": int(limit_down),
            "总家数": len(df),
        }
    except Exception:
        return {}


def fetch_north_flow(start_date: str, end_date: str) -> pd.DataFrame:
    """获取北向资金历史数据"""
    try:
        df = ak.stock_hsgt_hist_em(symbol="北向资金")
        df["日期"] = pd.to_datetime(df["日期"])
        mask = (df["日期"] >= pd.to_datetime(start_date)) & (df["日期"] <= pd.to_datetime(end_date))
        result = df.loc[mask].sort_values("日期").reset_index(drop=True)
        result = result.rename(columns={"日期": "date", "当日成交净买额": "net_flow"})
        return result
    except Exception:
        return pd.DataFrame()


def fetch_macro_news() -> list[str]:
    """获取全球财经要闻"""
    try:
        df = ak.stock_info_global_em()
        col = "content" if "content" in df.columns else df.columns[1] if len(df.columns) > 1 else df.columns[0]
        return df[col].head(10).tolist()
    except Exception:
        return []


def fetch_stock_news(symbol: str, count: int = 5) -> list[dict]:
    """获取个股相关新闻（东方财富源）
    返回: [{"title": "...", "time": "...", "source": "..."}]
    """
    try:
        df = ak.stock_news_em(symbol=symbol)
        results = []
        for _, row in df.head(count).iterrows():
            results.append({
                "title": row.get("新闻标题", ""),
                "time": str(row.get("发布时间", "")),
                "source": row.get("文章来源", ""),
            })
        return results
    except Exception:
        return []


def fetch_stock_hist_extended(symbol: str, days: int = 90) -> pd.DataFrame:
    """获取较长周期的历史数据，用于计算技术指标"""
    end = datetime.now()
    start = end - timedelta(days=days)
    return fetch_stock_daily(
        symbol,
        start.strftime("%Y%m%d"),
        end.strftime("%Y%m%d"),
    )


def fetch_index_hist_extended(symbol: str, days: int = 90) -> pd.DataFrame:
    """获取较长周期的指数历史数据"""
    end = datetime.now()
    start = end - timedelta(days=days)
    return fetch_index_daily(
        symbol,
        start.strftime("%Y%m%d"),
        end.strftime("%Y%m%d"),
    )


path: scripts/generate_weekly_report.py
content: #!/usr/bin/env python3
"""
每周金融投研报告生成器 - 主入口
用法: python generate_weekly_report.py --stocks 000001,600519 [--date 2026-03-14] [--author 名字]
"""
import argparse
import sys
import os
from datetime import datetime

import pandas as pd

from data_fetcher import (
    get_week_range,
    fetch_stock_hist_extended,
    fetch_index_hist_extended,
    fetch_stock_name_from_spot,
    fetch_market_breadth,
    fetch_north_flow,
    fetch_macro_news,
    fetch_stock_news,
)
from technical_analysis import analyze_stock
from report_generator import generate_report, render_pdf


# 常用指数 (新浪格式)
INDICES = {
    "上证指数": "sh000001",
    "深证成指": "sz399001",
    "沪深300": "sh000300",
    "创业板指": "sz399006",
}


def fetch_index_summary(start_date: str, end_date: str) -> dict:
    """获取主要指数本周表现"""
    result = {}
    for name, code in INDICES.items():
        try:
            df = fetch_index_hist_extended(code, days=30)
            if df.empty:
                result[name] = {}
                continue
            latest = df.iloc[-1]
            prev = df.iloc[-2] if len(df) >= 2 else latest
            pct = (latest["close"] / prev["close"] - 1) * 100
            result[name] = {
                "close": f"{latest['close']:.2f}",
                "pct_change": f"{pct:.2f}",
                "high": f"{latest['high']:.2f}",
                "low": f"{latest['low']:.2f}",
                "volume": f"{latest['volume'] / 1e8:.2f}亿",
            }
        except Exception as e:
            print(f"  [WARN] 获取{name}数据失败: {e}", file=sys.stderr)
            result[name] = {}
    return result


def fetch_north_summary(start_date: str, end_date: str) -> dict:
    """北向资金汇总"""
    try:
        df = fetch_north_flow(start_date, end_date)
        if df.empty:
            return {"total": 0}
        # net_flow 单位是万元，转亿
        total = df["net_flow"].sum()
        if abs(total) > 1e6:
            total = total / 1e4  # 万 -> 亿
        return {"total": total}
    except Exception:
        return {"total": 0}


def main():
    parser = argparse.ArgumentParser(description="每周金融投研报告生成器")
    parser.add_argument("--stocks", required=True, help="股票代码，逗号分隔，如: 000001,600519,000858")
    parser.add_argument("--date", default=None, help="报告日期，默认今天，格式: YYYY-MM-DD")
    parser.add_argument("--author", default="AI投研助手", help="作者名称")
    parser.add_argument("--output", default=None, help="输出文件路径，默认 output/周报_日期.md")
    parser.add_argument("--skip-breadth", action="store_true", help="跳过市场涨跌统计（较慢）")
    args = parser.parse_args()

    report_date = args.date or datetime.now().strftime("%Y-%m-%d")
    stock_list = [s.strip() for s in args.stocks.split(",") if s.strip()]
    week_range = get_week_range(report_date)

    print(f"=== 每周金融投研报告生成器 ===")
    print(f"报告日期: {report_date}")
    print(f"报告周期: {week_range[0]} - {week_range[1]}")
    print(f"跟踪标的: {stock_list}")
    print()

    # 1. 获取指数数据
    print("[1/5] 获取主要指数数据...")
    index_data = fetch_index_summary(week_range[0], week_range[1])

    # 2. 获取市场情绪
    market_breadth = {}
    if not args.skip_breadth:
        print("[2/5] 获取市场情绪指标（约1分钟）...")
        market_breadth = fetch_market_breadth()
    else:
        print("[2/5] 跳过市场情绪指标")

    # 3. 获取北向资金
    print("[3/5] 获取北向资金数据...")
    north_flow = fetch_north_summary(week_range[0], week_range[1])

    # 4. 获取个股数据并分析
    print("[4/5] 获取个股数据并进行技术分析...")
    stock_signals = []
    stock_dfs = {}  # 保存 DataFrame 用于图表生成
    for symbol in stock_list:
        try:
            name = fetch_stock_name_from_spot(symbol)
            print(f"  - 分析 {name}({symbol})...")
            df = fetch_stock_hist_extended(symbol, days=90)
            sig = analyze_stock(df, symbol, name)
            stock_signals.append(sig)
            stock_dfs[symbol] = df
        except Exception as e:
            print(f"  [WARN] {symbol} 分析失败: {e}", file=sys.stderr)

    # 5. 获取持仓相关新闻 + 宏观要闻
    print("[5/6] 获取持仓相关新闻...")
    stock_news = {}
    for symbol in stock_list:
        try:
            news = fetch_stock_news(symbol, count=3)
            if news:
                stock_news[symbol] = news
                print(f"  - {symbol}: {len(news)} 条新闻")
        except Exception:
            pass

    print("[6/6] 获取宏观要闻...")
    headlines = fetch_macro_news()

    # 生成报告
    print("\n正在生成报告...")
    report = generate_report(
        report_date=report_date,
        week_range=week_range,
        index_data=index_data,
        stock_signals=stock_signals,
        sector_flow=[],  # 板块资金流向在当前网络不可用，留空
        market_breadth=market_breadth,
        north_flow_summary=north_flow,
        macro_headlines=headlines,
        author=args.author,
        stock_dfs=stock_dfs,
        stock_news=stock_news,
    )

    # 输出 PDF
    output_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
    os.makedirs(output_dir, exist_ok=True)
    output_path = args.output or os.path.join(output_dir, f"周报_{report_date}.pdf")

    print(f"\n正在渲染 PDF...")
    render_pdf(report, output_path)

    print(f"报告已生成: {output_path}")
    file_size = os.path.getsize(output_path)
    print(f"文件大小: {file_size / 1024:.1f} KB")
    return output_path


if __name__ == "__main__":
    main()


path: scripts/report_generator.py
content: """
报告生成模块 - 生成每周金融投研报告 (PDF格式)
参考西南证券研报格式，使用 HTML+CSS 渲染后通过 weasyprint 转为 PDF。
内容紧凑排版，嵌入 matplotlib 图表。
"""
from datetime import datetime
from technical_analysis import TechnicalSignal
from chart_generator import (
    chart_stock_price_ma,
    chart_macd_rsi,
    chart_index_comparison,
    chart_signal_summary,
)
from weasyprint import HTML
import pandas as pd


# ============================================================
# CSS
# ============================================================
CSS = """
@page {
    size: A4;
    margin: 1.8cm 1.6cm 2cm 1.6cm;
    @bottom-center {
        content: "请务必阅读正文后的重要声明部分";
        font-size: 7pt; color: #aaa;
    }
    @bottom-right {
        content: counter(page);
        font-size: 8pt; color: #888;
    }
}
@page :first {
    @bottom-center { content: none; }
    @bottom-right  { content: none; }
}

body {
    font-family: "PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",sans-serif;
    font-size: 9.5pt; line-height: 1.55; color: #333;
}
h1,h2,h3,h4 { margin-top: 0.5em; margin-bottom: 0.2em; }
p { margin: 0.3em 0; }
ul { margin: 0.3em 0; padding-left: 1.4em; }
li { margin-bottom: 2px; }
img.chart { width: 100%; margin: 6px 0; }

/* ---- 封面 ---- */
.cover { page-break-after: always; padding-top: 30px; }
.cover-header {
    border-bottom: 3px solid #b00;
    padding-bottom: 4px; margin-bottom: 6px;
}
.cover-brand { color: #b00; font-size: 12pt; font-weight: bold; }
.cover-type { font-size: 9pt; color: #777; margin-left: 12px; }
.cover-title { font-size: 20pt; font-weight: bold; color: #222; margin: 18px 0 6px 0; }
.cover-period { font-size: 10pt; color: #555; margin-bottom: 16px; }
.cover-meta { font-size: 9pt; color: #555; line-height: 1.8; margin-bottom: 14px; }
.cover-meta strong { color: #333; }

.kp-box {
    border: 1px solid #b00; border-radius: 4px;
    padding: 10px 14px; background: #fdf6f6; margin-top: 14px;
}
.kp-box h3 { color: #b00; font-size: 11pt; margin: 0 0 6px 0; border-bottom: 1px solid #ecc; padding-bottom: 4px; }
.kp-box ul { padding-left: 16px; margin: 0; }
.kp-box li { font-size: 9pt; margin-bottom: 3px; }

.tag { display: inline-block; padding: 1px 8px; border-radius: 3px; font-size: 8pt; font-weight: bold; margin-right: 4px; }
.tag-r { background: #ffe0e0; color: #b00; }
.tag-g { background: #e0ffe0; color: #060; }
.tag-n { background: #e8e8e8; color: #555; }

/* ---- 章节 ---- */
h2.sec { color: #b00; font-size: 12pt; border-bottom: 2px solid #b00; padding-bottom: 3px; margin-top: 1em; }
h3.sub { font-size: 10pt; color: #333; border-left: 3px solid #b00; padding-left: 6px; margin-top: 0.8em; }

/* ---- 表格 ---- */
table { width: 100%; border-collapse: collapse; margin: 6px 0 10px 0; font-size: 8.5pt; }
th { background: #b00; color: #fff; padding: 4px 6px; text-align: center; font-weight: bold; font-size: 8pt; }
td { padding: 3px 6px; border-bottom: 1px solid #ddd; text-align: center; }
tr:nth-child(even) td { background: #f9f9f9; }

/* ---- 个股卡片 ---- */
.sc {
    border: 1px solid #ddd; border-radius: 4px;
    padding: 8px 12px; margin: 8px 0; background: #fafafa;
    page-break-inside: avoid;
}
.sc h4 { margin: 0 0 4px 0; color: #b00; font-size: 10.5pt; }
.sc .pl { font-size: 9pt; color: #555; margin-bottom: 6px; }
.sc .pl .pr { font-size: 14pt; font-weight: bold; color: #222; }
.up { color: #c00; } .dn { color: #090; }

/* 信号卡片 */
.sig-card {
    border-left: 3px solid #b00; padding: 6px 10px; margin: 6px 0;
    background: #fafafa; page-break-inside: avoid;
}
.sig-card h4 { margin: 0 0 4px 0; font-size: 10pt; }
.buy { color: #c00; font-weight: bold; }
.sell { color: #090; font-weight: bold; }

/* ---- 两栏指标 ---- */
.two-col { display: flex; gap: 10px; }
.two-col > div { flex: 1; }

/* ---- 风险/免责 ---- */
.risk-box {
    border: 1px solid #f5c6c6; background: #fef8f8;
    padding: 8px 12px; border-radius: 4px; margin: 8px 0; font-size: 8.5pt;
}
.disclaimer {
    margin-top: 16px; padding-top: 8px; border-top: 2px solid #b00;
    font-size: 8pt; color: #888; line-height: 1.7;
}
"""


# ============================================================
# HTML 片段
# ============================================================

def _cover(report_date, start, end, author, signals):
    bull = sum(1 for s in signals if len(s.buy_signals) > len(s.sell_signals))
    bear = sum(1 for s in signals if len(s.sell_signals) > len(s.buy_signals))
    neut = len(signals) - bull - bear

    items = ""
    for s in signals:
        d = "看多" if len(s.buy_signals) > len(s.sell_signals) else "看空" if len(s.sell_signals) > len(s.buy_signals) else "中性"
        items += f'<li><strong>{s.name}（{s.symbol}）</strong>：{d}，信号{s.signal_strength}，风险{s.risk_score:.1f}/10，仓位{s.position_coeff:.0%}</li>\n'

    return f"""
<div class="cover">
  <div class="cover-header">
    <span class="cover-brand">AI 投研助手</span>
    <span class="cover-type">证券研究报告 · 每周技术分析</span>
  </div>
  <div class="cover-title">每周金融投研报告</div>
  <div class="cover-period">报告周期：{start} 至 {end}</div>
  <div class="cover-meta">
    <strong>发布日期</strong>：{report_date} &nbsp;&nbsp;
    <strong>分析师</strong>：{author} &nbsp;&nbsp;
    <strong>数据来源</strong>：AKShare / 新浪财经 / 东方财富
  </div>
  <div class="kp-box">
    <h3>投资要点</h3>
    <p style="font-size:9pt">本周跟踪 {len(signals)} 只标的：
      <span class="tag tag-r">看多 {bull}</span>
      <span class="tag tag-g">看空 {bear}</span>
      <span class="tag tag-n">中性 {neut}</span>
    </p>
    <ul>{items}
      <li><em style="font-size:8.5pt">风险提示：市场波动、政策变化、流动性、模型误差风险。本报告仅供参考，不构成投资建议。</em></li>
    </ul>
  </div>
  <table style="margin-top:14px;font-size:8.5pt">
    <tr><th>数据类型</th><th>数据来源</th></tr>
    <tr><td>A股日线行情（前复权）</td><td>新浪财经</td></tr>
    <tr><td>主要指数日线</td><td>新浪财经</td></tr>
    <tr><td>个股实时行情</td><td>新浪财经</td></tr>
    <tr><td>市场涨跌统计</td><td>新浪财经</td></tr>
    <tr><td>北向资金</td><td>东方财富</td></tr>
    <tr><td>个股相关新闻</td><td>东方财富</td></tr>
    <tr><td>全球财经要闻</td><td>东方财富</td></tr>
  </table>
</div>"""


def _market(index_data, breadth, north_flow, headlines, idx_chart_b64, stock_news, signals):
    rows = ""
    for name, d in index_data.items():
        if d:
            p = d.get("pct_change", "0")
            c = "class='up'" if float(p) > 0 else "class='dn'" if float(p) < 0 else ""
            rows += f'<tr><td style="text-align:left">{name}</td><td>{d.get("close","-")}</td><td {c}>{p}%</td><td>{d.get("high","-")}</td><td>{d.get("low","-")}</td><td>{d.get("volume","-")}</td></tr>\n'

    chart_html = f'<img class="chart" src="data:image/png;base64,{idx_chart_b64}">' if idx_chart_b64 else ""

    breadth_html = ""
    if breadth:
        breadth_html = f"""
        <h3 class="sub">1.2 市场情绪</h3>
        <table><tr><th>上涨</th><th>下跌</th><th>平盘</th><th>涨停</th><th>跌停</th><th>总计</th></tr>
        <tr><td class="up">{breadth.get('上涨家数','-')}</td><td class="dn">{breadth.get('下跌家数','-')}</td>
        <td>{breadth.get('平盘家数','-')}</td><td class="up">{breadth.get('涨停家数','-')}</td>
        <td class="dn">{breadth.get('跌停家数','-')}</td><td>{breadth.get('总家数','-')}</td></tr></table>"""

    north = north_flow.get("total", 0) if north_flow else 0
    nc = "class='up'" if north > 0 else "class='dn'" if north < 0 else ""

    # 持仓相关资讯（个股新闻）
    stock_news_html = ""
    if stock_news:
        # 建立 symbol -> name 映射
        name_map = {s.symbol: s.name for s in signals}
        items = ""
        for symbol, news_list in stock_news.items():
            sname = name_map.get(symbol, symbol)
            for n in news_list[:3]:
                title = n.get("title", "")[:60]
                time_str = n.get("time", "")[:10]
                items += f'<li><strong>{sname}</strong>：{title} <span style="color:#aaa;font-size:7.5pt">({time_str})</span></li>\n'
        if items:
            stock_news_html = f"""
<h3 class="sub">1.4 持仓相关资讯</h3>
<ul style="font-size:8.5pt">{items}</ul>"""

    # 宏观要闻（精简）
    macro_html = ""
    if headlines:
        macro_items = ""
        for h in headlines[:4]:
            text = h[:70] + "…" if len(h) > 70 else h
            macro_items += f"<li>{text}</li>\n"
        macro_html = f"""
<h3 class="sub">1.5 宏观要闻</h3>
<ul style="font-size:8.5pt">{macro_items}</ul>"""

    return f"""
<h2 class="sec">1 &nbsp; 市场概览</h2>
<h3 class="sub">1.1 主要指数</h3>
<table><tr><th>指数</th><th>收盘价</th><th>涨跌幅</th><th>最高</th><th>最低</th><th>成交量</th></tr>{rows}</table>
{chart_html}
{breadth_html}
<h3 class="sub">1.3 北向资金</h3>
<p>本周净流入：<strong {nc}>{north:.2f} 亿元</strong></p>
{stock_news_html}
{macro_html}
"""


def _stock_section(signals, stock_dfs):
    blocks = ""
    for i, sig in enumerate(signals, 1):
        d = "看多" if len(sig.buy_signals) > len(sig.sell_signals) else "看空" if len(sig.sell_signals) > len(sig.buy_signals) else "中性"
        dc = "up" if d == "看多" else "dn" if d == "看空" else ""
        p1c = "up" if sig.pct_change_1d > 0 else "dn" if sig.pct_change_1d < 0 else ""
        p5c = "up" if sig.pct_change_5d > 0 else "dn" if sig.pct_change_5d < 0 else ""

        rsi_s = "超买" if sig.rsi > 70 else "超卖" if sig.rsi < 30 else "正常"
        macd_s = "多头" if sig.macd > sig.macd_signal else "空头"
        hist_s = "红柱" if sig.macd_hist > 0 else "绿柱"
        kdj_s = "超买" if sig.kdj_j > 100 else "超卖" if sig.kdj_j < 0 else "正常"
        vol_s = "放量" if sig.volume_ratio > 1.5 else "缩量" if sig.volume_ratio < 0.7 else "正常"
        ma_h = "趋势向好" if sig.ma_alignment == "多头排列" else "趋势偏弱" if sig.ma_alignment == "空头排列" else "方向不明"

        # 图表
        df = stock_dfs.get(sig.symbol)
        price_chart = ""
        indicator_chart = ""
        if df is not None and len(df) > 20:
            b64_price = chart_stock_price_ma(df, sig.name, sig.symbol)
            b64_ind = chart_macd_rsi(df, sig.name, sig.symbol)
            price_chart = f'<img class="chart" src="data:image/png;base64,{b64_price}">'
            indicator_chart = f'<img class="chart" src="data:image/png;base64,{b64_ind}">'

        blocks += f"""
<div class="sc">
  <h4>2.{i} &nbsp; {sig.name}（{sig.symbol}）</h4>
  <div class="pl">
    最新价 <span class="pr">{sig.close:.2f}</span> 元 &nbsp;|&nbsp;
    日涨跌 <span class="{p1c}">{sig.pct_change_1d:+.2f}%</span> &nbsp;|&nbsp;
    5日涨跌 <span class="{p5c}">{sig.pct_change_5d:+.2f}%</span> &nbsp;|&nbsp;
    趋势 <span class="{dc}" style="font-weight:bold">{d}</span>
  </div>
  {price_chart}
  <div class="two-col">
    <div>
      <table>
        <tr><th colspan="3">均线 &amp; 量价</th></tr>
        <tr><td>MA5</td><td>{sig.ma5:.2f}</td><td>MA10: {sig.ma10:.2f}</td></tr>
        <tr><td>MA20</td><td>{sig.ma20:.2f}</td><td>{sig.ma_alignment}（{ma_h}）</td></tr>
        <tr><td>量比</td><td>{sig.volume_ratio:.2f}（{vol_s}）</td><td>换手率: {sig.turnover_rate:.2f}%</td></tr>
      </table>
    </div>
    <div>
      <table>
        <tr><th colspan="3">动量 &amp; 波动</th></tr>
        <tr><td>RSI(14)</td><td>{sig.rsi:.1f}</td><td>{rsi_s}</td></tr>
        <tr><td>MACD柱</td><td>{sig.macd_hist:.4f}</td><td style="color:{'#c00' if sig.macd_hist > 0 else '#090'}">{hist_s}</td></tr>
        <tr><td>KDJ-J</td><td>{sig.kdj_j:.1f}</td><td>{kdj_s}</td></tr>
        <tr><td>ATR(14)</td><td>{sig.atr:.2f}</td><td>振幅: {sig.intraday_range:.2f}%</td></tr>
      </table>
    </div>
  </div>
  {indicator_chart}
</div>"""

    return f'<h2 class="sec">2 &nbsp; 个股技术分析</h2>\n{blocks}'


def _sector_flow(sector_flow):
    if not sector_flow:
        return ""  # 无数据不占空间
    rows = ""
    for s in sector_flow[:15]:
        rows += f'<tr><td style="text-align:left">{s.get("name","-")}</td><td>{s.get("change","-")}</td><td>{s.get("net_flow","-")}</td><td>{s.get("main_flow","-")}</td></tr>\n'
    return f"""
<h2 class="sec">3 &nbsp; 行业板块资金流向</h2>
<table><tr><th>行业</th><th>涨跌幅</th><th>净流入(亿)</th><th>主力净流入(亿)</th></tr>{rows}</table>"""


def _signals(signals, summary_chart_b64):
    chart_html = f'<img class="chart" src="data:image/png;base64,{summary_chart_b64}">' if summary_chart_b64 else ""
    cards = ""
    for sig in signals:
        d = "看多" if len(sig.buy_signals) > len(sig.sell_signals) else "看空" if len(sig.sell_signals) > len(sig.buy_signals) else "中性"
        dc = "#b00" if d == "看多" else "#090" if d == "看空" else "#666"
        buy = "、".join(sig.buy_signals) or "无"
        sell = "、".join(sig.sell_signals) or "无"
        cards += f"""
<div class="sig-card" style="border-left-color:{dc}">
  <h4>{sig.name}（{sig.symbol}）—— <span style="color:{dc}">{d}</span></h4>
  <table><tr><th>类型</th><th>详情</th></tr>
  <tr><td class="buy">买入</td><td style="text-align:left">{buy}</td></tr>
  <tr><td class="sell">卖出</td><td style="text-align:left">{sell}</td></tr>
  <tr><td>强度</td><td>{sig.signal_strength}</td></tr></table>
</div>"""

    return f"""
<h2 class="sec">__SEC_SIGNAL__ &nbsp; 交易信号汇总</h2>
{chart_html}
{cards}"""


def _risk(signals):
    rows = ""
    for s in signals:
        rows += f'<tr><td style="text-align:left">{s.name}（{s.symbol}）</td><td>{s.risk_score:.1f}</td><td>{s.position_coeff:.0%}</td><td>{s.stop_loss:.2f}</td><td>{s.take_profit:.2f}</td></tr>\n'
    return f"""
<h2 class="sec">__SEC_RISK__ &nbsp; 风险评估与仓位建议</h2>
<table><tr><th>标的</th><th>风险评分</th><th>仓位系数</th><th>止损位</th><th>止盈位</th></tr>{rows}</table>
<p style="font-size:8.5pt"><strong>止损位</strong> = min(前日低点×0.995, MA10×0.99, 入场价×0.95)
&nbsp;&nbsp; <strong>止盈位</strong> = 当前价 + ATR×2
&nbsp;&nbsp; <strong>仓位系数</strong> = (10−风险评分)/10</p>"""


def _disclaimer(author):
    return f"""
<h2 class="sec">__SEC_DISCL__ &nbsp; 风险提示</h2>
<div class="risk-box">
<ul>
  <li><strong>市场系统性风险</strong>：宏观经济下行、地缘政治等可能导致整体下跌</li>
  <li><strong>政策风险</strong>：监管变化可能对行业/个股产生重大影响</li>
  <li><strong>流动性风险</strong>：部分标的流动性不足，可能无法及时止损</li>
  <li><strong>模型风险</strong>：技术分析基于历史数据，不代表未来走势</li>
  <li><strong>数据风险</strong>：公开市场数据可能存在延迟或误差</li>
</ul>
</div>
<div class="disclaimer">
  <strong>重要声明</strong>：本报告由 {author} 基于公开市场数据自动生成（AKShare / 新浪财经 / 东方财富），仅供参考和学习交流，<strong>不构成任何投资建议</strong>。<br>
  <strong>指标体系</strong>：均线 MA5/10/20 | RSI(14) | MACD(12,26,9) | KDJ(9,3,3) | ATR(14) | 量比(5日) | 换手率
  <p style="text-align:center;margin-top:12px;color:#aaa;font-size:7.5pt">
    报告生成：{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} &nbsp; Powered by AKShare &amp; OpenClaw AI
  </p>
</div>"""


# ============================================================
# 公开接口
# ============================================================

def generate_report(
    report_date: str,
    week_range: tuple[str, str],
    index_data: dict,
    stock_signals: list[TechnicalSignal],
    sector_flow: list[dict],
    market_breadth: dict,
    north_flow_summary: dict,
    macro_headlines: list[str],
    author: str = "AI投研助手",
    stock_dfs: dict[str, pd.DataFrame] | None = None,
    stock_news: dict[str, list[dict]] | None = None,
) -> str:
    """生成完整 HTML（含内嵌图表），通过 render_pdf 转 PDF"""
    start = f"{week_range[0][:4]}-{week_range[0][4:6]}-{week_range[0][6:]}"
    end = f"{week_range[1][:4]}-{week_range[1][4:6]}-{week_range[1][6:]}"
    stock_dfs = stock_dfs or {}
    stock_news = stock_news or {}

    # 生成图表
    idx_chart = chart_index_comparison(index_data)
    sig_chart = chart_signal_summary(stock_signals)

    has_sector = bool(sector_flow)

    body = "".join([
        _cover(report_date, start, end, author, stock_signals),
        _market(index_data, market_breadth, north_flow_summary, macro_headlines, idx_chart, stock_news, stock_signals),
        _stock_section(stock_signals, stock_dfs),
        _sector_flow(sector_flow),
        _signals(stock_signals, sig_chart),
        _risk(stock_signals),
        _disclaimer(author),
    ])

    # 动态章节编号
    if has_sector:
        body = body.replace("__SEC_SIGNAL__", "4")
        body = body.replace("__SEC_RISK__", "5")
        body = body.replace("__SEC_DISCL__", "6")
    else:
        body = body.replace("__SEC_SIGNAL__", "3")
        body = body.replace("__SEC_RISK__", "4")
        body = body.replace("__SEC_DISCL__", "5")

    return f"""<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><style>{CSS}</style></head>
<body>{body}</body></html>"""


def render_pdf(html_content: str, output_path: str) -> str:
    """将 HTML 渲染为 PDF，通过 Ghostscript 优化以兼容 macOS Preview"""
    import subprocess, tempfile, shutil, os

    tmp_path = output_path + ".tmp.pdf"
    HTML(string=html_content).write_pdf(tmp_path)

    gs_bin = shutil.which("gs")
    if gs_bin:
        try:
            subprocess.run([
                gs_bin, "-dNOPAUSE", "-dBATCH", "-dSAFER",
                "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
                "-dPDFSETTINGS=/prepress",
                "-dEmbedAllFonts=true", "-dSubsetFonts=true",
                f"-sOutputFile={output_path}", tmp_path,
            ], check=True, capture_output=True)
            os.remove(tmp_path)
        except subprocess.CalledProcessError:
            os.replace(tmp_path, output_path)
    else:
        os.replace(tmp_path, output_path)

    return output_path


path: scripts/technical_analysis.py
content: """
技术分析模块 - 计算各类技术指标并生成交易信号
参考：日频短期股票分析框架
"""
import pandas as pd
import numpy as np
from dataclasses import dataclass, field


@dataclass
class TechnicalSignal:
    """技术分析信号汇总"""
    symbol: str
    name: str = ""
    date: str = ""
    close: float = 0.0
    pct_change_1d: float = 0.0
    pct_change_5d: float = 0.0

    # 均线
    ma5: float = 0.0
    ma10: float = 0.0
    ma20: float = 0.0
    ma_alignment: str = ""  # 多头/空头/震荡

    # 成交量
    volume_ratio: float = 0.0  # 量比
    turnover_rate: float = 0.0
    volume_trend: str = ""  # 放量/缩量/平稳

    # 波动率
    atr: float = 0.0
    std_dev: float = 0.0
    intraday_range: float = 0.0

    # 动量指标
    rsi: float = 0.0
    macd: float = 0.0
    macd_signal: float = 0.0
    macd_hist: float = 0.0
    kdj_k: float = 0.0
    kdj_d: float = 0.0
    kdj_j: float = 0.0

    # 信号
    buy_signals: list = field(default_factory=list)
    sell_signals: list = field(default_factory=list)
    signal_strength: str = ""  # 强/中/弱
    risk_score: float = 0.0  # 0-10
    position_coeff: float = 0.0

    # 止损止盈
    stop_loss: float = 0.0
    take_profit: float = 0.0

    def summary(self) -> str:
        direction = "看多" if len(self.buy_signals) > len(self.sell_signals) else "看空" if len(self.sell_signals) > len(self.buy_signals) else "中性"
        return f"{direction} | 信号强度: {self.signal_strength} | 风险评分: {self.risk_score:.1f}/10"


def calc_ma(series: pd.Series, period: int) -> pd.Series:
    return series.rolling(window=period).mean()


def calc_rsi(series: pd.Series, period: int = 14) -> pd.Series:
    delta = series.diff()
    gain = delta.where(delta > 0, 0.0)
    loss = (-delta).where(delta < 0, 0.0)
    avg_gain = gain.rolling(window=period, min_periods=period).mean()
    avg_loss = loss.rolling(window=period, min_periods=period).mean()
    # 使用 EMA 方式平滑
    for i in range(period, len(series)):
        avg_gain.iloc[i] = (avg_gain.iloc[i - 1] * (period - 1) + gain.iloc[i]) / period
        avg_loss.iloc[i] = (avg_loss.iloc[i - 1] * (period - 1) + loss.iloc[i]) / period
    rs = avg_gain / avg_loss
    rsi = 100 - (100 / (1 + rs))
    return rsi


def calc_macd(series: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9):
    ema_fast = series.ewm(span=fast, adjust=False).mean()
    ema_slow = series.ewm(span=slow, adjust=False).mean()
    macd_line = ema_fast - ema_slow
    signal_line = macd_line.ewm(span=signal, adjust=False).mean()
    histogram = macd_line - signal_line
    return macd_line, signal_line, histogram


def calc_kdj(df: pd.DataFrame, n: int = 9, m1: int = 3, m2: int = 3):
    low_n = df["low"].rolling(window=n).min()
    high_n = df["high"].rolling(window=n).max()
    rsv = (df["close"] - low_n) / (high_n - low_n) * 100
    rsv = rsv.fillna(50)
    k = rsv.ewm(alpha=1 / m1, adjust=False).mean()
    d = k.ewm(alpha=1 / m2, adjust=False).mean()
    j = 3 * k - 2 * d
    return k, d, j


def calc_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
    high_low = df["high"] - df["low"]
    high_close = (df["high"] - df["close"].shift(1)).abs()
    low_close = (df["low"] - df["close"].shift(1)).abs()
    true_range = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
    return true_range.rolling(window=period).mean()


def calc_volume_ratio(volume: pd.Series, period: int = 5) -> float:
    """量比 = 当日成交量 / 过去N日平均成交量"""
    if len(volume) < period + 1:
        return 1.0
    avg = volume.iloc[-(period + 1):-1].mean()
    if avg == 0:
        return 1.0
    return volume.iloc[-1] / avg


def determine_ma_alignment(ma5: float, ma10: float, ma20: float) -> str:
    if ma5 > ma10 > ma20:
        return "多头排列"
    elif ma5 < ma10 < ma20:
        return "空头排列"
    else:
        return "震荡整理"


def determine_volume_trend(volume: pd.Series, period: int = 5) -> str:
    if len(volume) < period + 1:
        return "数据不足"
    recent_avg = volume.iloc[-period:].mean()
    prev_avg = volume.iloc[-(period * 2):-period].mean() if len(volume) >= period * 2 else volume.mean()
    ratio = recent_avg / prev_avg if prev_avg > 0 else 1.0
    if ratio > 1.3:
        return "放量"
    elif ratio < 0.7:
        return "缩量"
    else:
        return "平稳"


def generate_buy_signals(sig: TechnicalSignal, df: pd.DataFrame) -> list[str]:
    signals = []
    ma5_series = calc_ma(df["close"], 5)
    ma10_series = calc_ma(df["close"], 10)

    # MA5上穿MA10（金叉）
    if len(ma5_series) >= 2 and len(ma10_series) >= 2:
        if ma5_series.iloc[-2] <= ma10_series.iloc[-2] and ma5_series.iloc[-1] > ma10_series.iloc[-1]:
            signals.append("MA5上穿MA10（金叉）")

    # 价格突破MA10且站稳
    if sig.close > sig.ma10 and df["close"].iloc[-2] <= ma10_series.iloc[-2]:
        signals.append("价格突破MA10")

    # 量比>1.2且价格上涨
    if sig.volume_ratio > 1.2 and sig.pct_change_1d > 0:
        signals.append(f"量价齐升（量比{sig.volume_ratio:.2f}）")

    # RSI > 50 且 < 70
    if 50 < sig.rsi < 70:
        signals.append(f"RSI动量良好（{sig.rsi:.1f}）")

    # MACD柱状线由负转正
    _, _, hist = calc_macd(df["close"])
    if len(hist) >= 2 and hist.iloc[-2] < 0 and hist.iloc[-1] > 0:
        signals.append("MACD柱状线翻红")

    return signals


def generate_sell_signals(sig: TechnicalSignal, df: pd.DataFrame) -> list[str]:
    signals = []
    ma5_series = calc_ma(df["close"], 5)
    ma10_series = calc_ma(df["close"], 10)

    # MA5下穿MA10（死叉）
    if len(ma5_series) >= 2 and len(ma10_series) >= 2:
        if ma5_series.iloc[-2] >= ma10_series.iloc[-2] and ma5_series.iloc[-1] < ma10_series.iloc[-1]:
            signals.append("MA5下穿MA10（死叉）")

    # 价格跌破MA5且成交量放大
    if sig.close < sig.ma5 and sig.volume_ratio > 1.2 and sig.pct_change_1d < 0:
        signals.append("放量跌破MA5")

    # RSI > 80（超买）
    if sig.rsi > 80:
        signals.append(f"RSI超买（{sig.rsi:.1f}）")

    # 5日累计涨幅 > 15%
    if sig.pct_change_5d > 15:
        signals.append(f"5日累计涨幅过大（{sig.pct_change_5d:.1f}%）")

    # KDJ超买
    if sig.kdj_j > 100:
        signals.append(f"KDJ超买（J={sig.kdj_j:.1f}）")

    return signals


def calc_risk_score(sig: TechnicalSignal) -> float:
    """风险评分 0-10，越高风险越大"""
    score = 5.0  # 基准分

    # RSI风险
    if sig.rsi > 80:
        score += 2.0
    elif sig.rsi > 70:
        score += 1.0
    elif sig.rsi < 30:
        score += 1.5
    elif sig.rsi < 20:
        score += 2.5

    # 波动率风险
    if sig.close > 0:
        atr_pct = sig.atr / sig.close * 100
        if atr_pct > 5:
            score += 1.5
        elif atr_pct > 3:
            score += 0.5

    # 均线排列
    if sig.ma_alignment == "空头排列":
        score += 1.0
    elif sig.ma_alignment == "多头排列":
        score -= 1.0

    # 量比异常
    if sig.volume_ratio > 3:
        score += 1.0

    # 5日涨幅过大
    if sig.pct_change_5d > 15:
        score += 1.5
    elif sig.pct_change_5d < -10:
        score += 1.0

    return max(0.0, min(10.0, score))


def analyze_stock(df: pd.DataFrame, symbol: str, name: str = "") -> TechnicalSignal:
    """对单只股票进行完整技术分析"""
    sig = TechnicalSignal(symbol=symbol, name=name)

    if df.empty or len(df) < 20:
        return sig

    sig.date = df["date"].iloc[-1].strftime("%Y-%m-%d")
    sig.close = df["close"].iloc[-1]
    sig.pct_change_1d = ((df["close"].iloc[-1] / df["close"].iloc[-2]) - 1) * 100 if len(df) >= 2 else 0
    sig.pct_change_5d = ((df["close"].iloc[-1] / df["close"].iloc[-6]) - 1) * 100 if len(df) >= 6 else 0

    # 均线
    ma5 = calc_ma(df["close"], 5)
    ma10 = calc_ma(df["close"], 10)
    ma20 = calc_ma(df["close"], 20)
    sig.ma5 = ma5.iloc[-1]
    sig.ma10 = ma10.iloc[-1]
    sig.ma20 = ma20.iloc[-1]
    sig.ma_alignment = determine_ma_alignment(sig.ma5, sig.ma10, sig.ma20)

    # 成交量
    sig.volume_ratio = calc_volume_ratio(df["volume"])
    sig.turnover_rate = df["turnover_rate"].iloc[-1] if "turnover_rate" in df.columns else 0.0
    sig.volume_trend = determine_volume_trend(df["volume"])

    # 波动率
    atr = calc_atr(df)
    sig.atr = atr.iloc[-1] if not atr.empty else 0
    sig.std_dev = df["close"].iloc[-20:].std()
    sig.intraday_range = (df["high"].iloc[-1] - df["low"].iloc[-1]) / df["close"].iloc[-1] * 100

    # 动量指标
    rsi = calc_rsi(df["close"])
    sig.rsi = rsi.iloc[-1] if not rsi.empty and not pd.isna(rsi.iloc[-1]) else 50

    macd_line, signal_line, hist = calc_macd(df["close"])
    sig.macd = macd_line.iloc[-1]
    sig.macd_signal = signal_line.iloc[-1]
    sig.macd_hist = hist.iloc[-1]

    k, d, j = calc_kdj(df)
    sig.kdj_k = k.iloc[-1]
    sig.kdj_d = d.iloc[-1]
    sig.kdj_j = j.iloc[-1]

    # 信号判断
    sig.buy_signals = generate_buy_signals(sig, df)
    sig.sell_signals = generate_sell_signals(sig, df)

    # 风险评估
    sig.risk_score = calc_risk_score(sig)
    sig.position_coeff = max(0, (10 - sig.risk_score)) / 10

    # 信号强度
    net_signal = len(sig.buy_signals) - len(sig.sell_signals)
    if abs(net_signal) >= 3:
        sig.signal_strength = "强"
    elif abs(net_signal) >= 1:
        sig.signal_strength = "中"
    else:
        sig.signal_strength = "弱"

    # 止损止盈
    prev_low = df["low"].iloc[-2] if len(df) >= 2 else sig.close
    sig.stop_loss = min(prev_low * 0.995, sig.ma10 * 0.99, sig.close * 0.95)
    sig.take_profit = sig.close + sig.atr * 2 if sig.atr > 0 else sig.close * 1.08

    return sig


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