claude强化功能
This commit is contained in:
5
.continue/rules/new-rule.md
Normal file
5
.continue/rules/new-rule.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: A description of your rule
|
||||
---
|
||||
|
||||
work in windows wsl.
|
||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
1321
20260613T161709_session.md
Normal file
1321
20260613T161709_session.md
Normal file
File diff suppressed because it is too large
Load Diff
181
README.md
181
README.md
@@ -1,19 +1,27 @@
|
||||
# 智策 StockTerminal
|
||||
# Blackdata StockTerminal
|
||||
|
||||
个人/小团队 A 股分析·复盘·智能辅助系统。后端提供行情、回测、AI 诊断与定时任务;前端为纯 HTML + ECharts 原型界面,由 FastAPI 统一托管。
|
||||
个人/小团队 A 股分析·复盘·智能专业分析系统。后端提供行情、回测、AI 诊断与定时任务;前端为纯 HTML + ECharts 原型界面,由 FastAPI 统一托管。
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 模块 | 能力 |
|
||||
|---|---|
|
||||
| 大盘行情 | 三大指数、情绪温度计、板块云图、热股榜、龙虎榜 |
|
||||
| 自选股 | 自选列表、内置策略选股、多因子条件过滤 |
|
||||
| 复盘中心 | 每日复盘、AI 七段式日报、个股 K 线回放 |
|
||||
| 策略与中台 | MA 交叉回测、数据入库状态、任务日志 |
|
||||
| AI 分析 | 个股诊断(6 维证据链)、信号胜率、预测留痕与核验 |
|
||||
| 组合交易 | 持仓 P&L、资金曲线、交易日志、盈亏归因 |
|
||||
| 智能预警 | 价格/涨跌幅规则、多通道推送、触发记录 |
|
||||
| 资讯中心 | 财经快讯、情绪标注、自选相关资讯 |
|
||||
| **大盘行情** | 三大指数、情绪温度计、板块云图、热股榜、龙虎榜、涨跌停统计 |
|
||||
| **盘中监控** | 异动雷达(快速拉升/放量突破/涨停打开/连板追踪/大单异动)、实时扫描与推送 |
|
||||
| **自选股** | 自选列表、分组管理、内置 8 种策略选股、多因子条件过滤 |
|
||||
| **智能选股** | 可视化条件组合器、选股策略保存/分享、选股结果回测验证、条件预警集成 |
|
||||
| **复盘中心** | 每日复盘(板块/资金/龙虎榜)、AI 七段式日报、个股 K 线回放(MA 买卖点标注) |
|
||||
| **策略回测** | MA 交叉/多因子策略回测、参数优化网格搜索、策略对比(并排净值曲线)、交易明细导出 |
|
||||
| **板块轮动** | 板块强弱趋势、资金流向桑基图、龙头股识别、生命周期判断、板块联动性分析 |
|
||||
| **AI 分析** | 个股诊断(6 维证据链)、AI 对话式分析、信号历史胜率、预测留痕与准确率核验 |
|
||||
| **组合交易** | 持仓 P&L、资金曲线、交易日志(理由/情绪标签)、持仓归因分析(选股/择时/运气分解) |
|
||||
| **智能预警** | 价格/涨跌幅/量能/技术信号规则、选股策略预警、多通道推送(邮件/微信/企微)、触发记录 |
|
||||
| **资讯中心** | 财经快讯、AI 情绪判断与摘要、自选股相关资讯、关联个股分析 |
|
||||
| **社区情绪** | 热帖采集(东方财富/雪球)、情绪指数计算、热议股票排行、关键词云图、情绪与股价相关性 |
|
||||
| **事件驱动** | 财报发布前后规律、高管增减持跟踪、限售解禁影响、行业政策事件库、事件驱动选股 |
|
||||
| **财报解读** | 关键指标趋势、AI 财报摘要、同行对比、财报异常预警、发布日历、排行榜 |
|
||||
| **涨跌停分析** | 涨停/跌停股票追踪、连板股监控、炸板率统计、涨停敢死队排行 |
|
||||
| **数据中台** | 数据入库状态、任务日志、全市场历史回填、定时调度监控 |
|
||||
|
||||
更完整的架构说明见 [架构总结.md](./架构总结.md)。
|
||||
|
||||
@@ -31,13 +39,38 @@
|
||||
```
|
||||
stock_cs/
|
||||
├── backend/ # FastAPI 后端
|
||||
│ ├── main.py # API 入口 + 静态文件托管
|
||||
│ ├── main.py # API 入口 + 路由定义
|
||||
│ ├── cli.py # 建库/入库命令行工具
|
||||
│ ├── models.py # SQLAlchemy 数据模型
|
||||
│ ├── db.py # 数据库连接管理
|
||||
│ ├── config.py # 配置项
|
||||
│ ├── scheduler.py # APScheduler 定时任务
|
||||
│ ├── akshare_service.py # 数据源接口封装
|
||||
│ ├── ai.py # AI 分析核心
|
||||
│ ├── ai_chat.py # AI 对话式分析
|
||||
│ ├── llm.py # 大模型调用封装
|
||||
│ ├── backtest.py # 基础回测引擎
|
||||
│ ├── backtest_advanced.py # 增强回测(多因子/参数优化/策略对比)
|
||||
│ ├── signals.py # 信号胜率统计
|
||||
│ ├── report.py # AI 复盘日报生成
|
||||
│ ├── portfolio.py # 组合与持仓计算
|
||||
│ ├── attribution_analysis.py # 持仓归因分析
|
||||
│ ├── alerts.py # 智能预警核心
|
||||
│ ├── notifier.py # 多通道推送
|
||||
│ ├── intraday_radar.py # 盘中异动雷达
|
||||
│ ├── sector_rotation.py # 板块轮动分析
|
||||
│ ├── smart_selector.py # 智能选股增强
|
||||
│ ├── sentiment_monitor.py # 社区情绪监控
|
||||
│ ├── event_driven.py # 事件驱动策略
|
||||
│ ├── financial_analysis.py # 财报深度解读
|
||||
│ ├── limit_analysis.py # 涨跌停分析
|
||||
│ ├── .env.example # 环境变量模板
|
||||
│ └── requirements.txt
|
||||
├── prototype/ # 前端原型(index.html / app.js / style.css)
|
||||
├── 架构总结.md
|
||||
└── 功能架构.md
|
||||
│ └── requirements.txt # Python 依赖
|
||||
├── prototype/ # 前端原型(HTML + JS + CSS)
|
||||
├── 架构总结.md # 架构设计文档
|
||||
├── 功能架构.md # 功能模块详解
|
||||
├── 待优化.md # 已知问题与优化方向
|
||||
└── 功能扩展.md # 扩展功能建议
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
@@ -48,28 +81,51 @@ stock_cs/
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
以下命令以 **WSL(Linux)** 为例。项目在 Windows 盘时,路径一般为 `/mnt/e/project/stock_cs_v1`;若在 WSL 家目录,则替换为实际路径即可。
|
||||
|
||||
### 1. 安装 PostgreSQL(WSL,首次)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql postgresql-contrib
|
||||
sudo service postgresql start
|
||||
|
||||
# Windows
|
||||
.venv\Scripts\activate
|
||||
# 设置 postgres 用户密码(与 backend/.env 中 PG_PASSWORD 一致)
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'your_password';"
|
||||
```
|
||||
|
||||
# Linux / macOS
|
||||
WSL 每次重启后若数据库未自动运行,需先执行:
|
||||
|
||||
```bash
|
||||
sudo service postgresql start
|
||||
```
|
||||
|
||||
### 2. 安装 Python 依赖(首次)
|
||||
|
||||
```bash
|
||||
cd /mnt/e/project/stock_cs_v1/backend # 按实际路径修改
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
**Windows 原生(非 WSL)** 激活虚拟环境:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `backend/.env`,至少确认 PostgreSQL 连接信息(也可通过环境变量 `PG_USER` / `PG_PASSWORD` / `PG_HOST` / `PG_PORT` / `PG_DB` 设置):
|
||||
编辑 `backend/.env`,至少确认 PostgreSQL 连接信息(PostgreSQL 装在 WSL 内时使用 `localhost`):
|
||||
|
||||
```env
|
||||
PG_USER=postgres
|
||||
@@ -79,12 +135,15 @@ PG_PORT=5432
|
||||
PG_DB=stock_cs
|
||||
```
|
||||
|
||||
也可通过环境变量 `PG_USER` / `PG_PASSWORD` / `PG_HOST` / `PG_PORT` / `PG_DB` 设置,无需改文件。
|
||||
|
||||
可选:填入 `LLM_API_KEY` 启用大模型分析;填入 SMTP / Server酱 / 企业微信 / PushPlus 启用推送。
|
||||
|
||||
### 3. 初始化数据库并入库
|
||||
### 4. 初始化数据库并入库(首次)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate # WSL / Linux
|
||||
|
||||
# 建库建表
|
||||
python cli.py init
|
||||
@@ -92,7 +151,7 @@ python cli.py init
|
||||
# 抓取当日板块/资金流/情绪/龙虎榜等快照
|
||||
python cli.py ingest
|
||||
|
||||
# 全市场日线历史入库(默认 250 交易日,耗时较长)
|
||||
# 全市场日线历史入库(默认 250 交易日,耗时较长,可选)
|
||||
python cli.py ingest_all
|
||||
python cli.py ingest_all 500 # 指定天数
|
||||
```
|
||||
@@ -103,28 +162,49 @@ python cli.py ingest_all 500 # 指定天数
|
||||
python cli.py ingest 600519 000001
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
### 5. 启动服务
|
||||
|
||||
**日常启动(WSL):**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
sudo service postgresql start
|
||||
cd /mnt/e/project/stock_cs_v1/backend # 按实际路径修改
|
||||
source .venv/bin/activate
|
||||
python main.py
|
||||
```
|
||||
|
||||
浏览器访问:**http://127.0.0.1:8000**
|
||||
一键命令(已配置好后):
|
||||
|
||||
```bash
|
||||
sudo service postgresql start && cd /mnt/e/project/stock_cs_v1/backend && source .venv/bin/activate && python main.py
|
||||
```
|
||||
|
||||
浏览器访问:**http://127.0.0.1:8000**(WSL2 下 Windows 浏览器可直接访问)
|
||||
|
||||
健康检查:`GET /api/health`
|
||||
|
||||
### 常见问题(WSL)
|
||||
|
||||
| 现象 | 处理 |
|
||||
|---|---|
|
||||
| `connection refused` | 执行 `sudo service postgresql start` |
|
||||
| `password authentication failed` | 检查 `.env` 中 `PG_PASSWORD` 是否与 `ALTER USER` 设置一致 |
|
||||
| `python: command not found` | 使用 `python3` |
|
||||
| 每次新开终端 | 先 `source .venv/bin/activate` 再运行命令 |
|
||||
|
||||
## 定时任务
|
||||
|
||||
服务启动后,APScheduler 会在工作日自动执行(可在 `config.py` 或环境变量中调整时间):
|
||||
|
||||
| 任务 | 默认时间 | 说明 |
|
||||
|---|---|---|
|
||||
| `daily_ingest` | 15:35 | 收盘后增量入库 |
|
||||
| `alert_check` | 每 60 秒 | 实时报价预警核查 |
|
||||
| `daily_report` | 15:45 | 生成 AI 复盘日报并推送 |
|
||||
| `verify_pred` | 15:50 | 核验到期 AI 预测 |
|
||||
| `signal_stats` | 周六 09:00 | 全市场信号胜率回测 |
|
||||
| `daily_ingest` | 15:35 | 收盘后增量入库(板块/资金/情绪/龙虎榜/个股行情) |
|
||||
| `alert_check` | 每 60 秒 | 实时报价预警核查(价格/涨跌幅/量能等规则) |
|
||||
| `intraday_scan` | 交易时段每 5 分钟 | 盘中异动扫描(快速拉升/放量突破/涨停打开/连板追踪) |
|
||||
| `daily_report` | 15:45 | 生成 AI 复盘日报并推送(需配置大模型 API) |
|
||||
| `verify_pred` | 15:50 | 核验到期 AI 预测,更新准确率统计 |
|
||||
| `signal_stats` | 周六 09:00 | 全市场信号胜率回测(MACD 金叉/突破等技术信号) |
|
||||
| `selector_check` | 15:40 | 选股策略预警检查,符合条件时推送 |
|
||||
|
||||
## 推送渠道
|
||||
|
||||
@@ -144,10 +224,41 @@ python main.py
|
||||
- AkShare 不可用时部分接口会降级为 mock 数据,详见 `/api/health` 中的 `akshare` 字段。
|
||||
- 敏感文件(`.env`、虚拟环境等)已在 `.gitignore` 中排除,请勿提交密钥。
|
||||
|
||||
## 核心功能说明
|
||||
|
||||
### 1. 智能选股增强
|
||||
可视化条件组合器,支持技术面、资金面、基本面多因子拖拽组合,选股结果可一键回测验证历史表现,策略可保存/分享并设置条件预警。详见 [智能选股增强使用说明.md](./智能选股增强使用说明.md)
|
||||
|
||||
### 2. 盘中异动雷达
|
||||
交易时段自动扫描快速拉升、放量突破、涨停打开、连板股等异动信号,支持多通道实时推送。详见 [盘中异动雷达使用说明.md](./盘中异动雷达使用说明.md)
|
||||
|
||||
### 3. 板块轮动分析
|
||||
板块强弱趋势、资金流向桑基图、生命周期判断(启动期/加速期/衰退期)、龙头股自动识别、板块联动性分析。详见 [板块轮动分析使用说明.md](./板块轮动分析使用说明.md)
|
||||
|
||||
### 4. 策略回测增强
|
||||
多因子组合回测、仓位管理策略、参数优化网格搜索、策略对比(并排净值曲线)、完整风险指标(夏普/最大回撤/胜率)。详见 [策略回测增强使用说明.md](./策略回测增强使用说明.md)
|
||||
|
||||
### 5. 持仓归因分析
|
||||
收益归因分解(选股能力 vs 择时能力 vs 运气成分)、持仓时长分析、买入理由有效性验证、情绪标签相关性、对标指数超额收益拆解。详见 [持仓归因分析深化使用说明.md](./持仓归因分析深化使用说明.md)
|
||||
|
||||
### 6. AI 对话式分析
|
||||
与大模型深度结合,支持自然语言选股、持仓诊断、策略建议、实时问答,多轮对话记住用户偏好。详见 [AI对话式分析使用说明.md](./AI对话式分析使用说明.md)
|
||||
|
||||
### 7. 社区情绪监控
|
||||
爬取东方财富/雪球热帖,计算情绪指数(乐观/悲观比例)、热议股票排行、关键词云图、情绪与股价相关性回测。详见 [社区情绪监控使用说明.md](./社区情绪监控使用说明.md)
|
||||
|
||||
### 8. 事件驱动策略
|
||||
财报发布前后统计规律、高管增减持跟踪、限售解禁影响分析、行业政策事件库、事件驱动选股。详见 [事件驱动策略使用说明.md](./事件驱动策略使用说明.md)
|
||||
|
||||
### 9. 财报深度解读
|
||||
财报关键指标趋势、AI 一句话摘要、同行对比、财报异常预警(存货激增/应收账款占比过高)、发布日历提醒。详见 [财报深度解读使用说明.md](./财报深度解读使用说明.md)
|
||||
|
||||
## 文档
|
||||
|
||||
- [架构总结.md](./架构总结.md) — 分层设计、数据模型、AI 分析流程
|
||||
- [功能架构.md](./功能架构.md) — 功能模块说明
|
||||
- [功能架构.md](./功能架构.md) — 功能模块详细说明
|
||||
- [待优化.md](./待优化.md) — 已知问题与优化方向
|
||||
- [功能扩展.md](./功能扩展.md) — 扩展功能建议
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
542
backend/ai_chat.py
Normal file
542
backend/ai_chat.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""AI 对话式分析 — 自然语言交互的炒股助手。
|
||||
|
||||
功能:
|
||||
1. 自然语言选股
|
||||
2. 持仓诊断对话
|
||||
3. 策略建议
|
||||
4. 实时问答
|
||||
5. 上下文记忆(多轮对话)
|
||||
"""
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, date
|
||||
|
||||
import llm
|
||||
import smart_selector as selector
|
||||
import portfolio as pf
|
||||
import ai
|
||||
import sector_rotation as sector
|
||||
from db import get_session
|
||||
from models import StockMetric, Trade
|
||||
|
||||
# 会话上下文存储
|
||||
_SESSIONS = {} # {session_id: {"messages": [], "context": {}}}
|
||||
|
||||
|
||||
def get_or_create_session(session_id: str) -> Dict:
|
||||
"""获取或创建会话"""
|
||||
if session_id not in _SESSIONS:
|
||||
_SESSIONS[session_id] = {
|
||||
"messages": [],
|
||||
"context": {},
|
||||
"created_at": datetime.now().isoformat()
|
||||
}
|
||||
return _SESSIONS[session_id]
|
||||
|
||||
|
||||
def chat(session_id: str, user_message: str) -> Dict[str, Any]:
|
||||
"""AI对话主入口
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
user_message: 用户消息
|
||||
|
||||
Returns:
|
||||
AI回复
|
||||
"""
|
||||
if not llm.enabled():
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "大模型未配置,请在 backend/.env 中配置 LLM_API_KEY",
|
||||
"text": "抱歉,AI对话功能需要配置大模型。您可以:\n1. 配置 .env 中的 LLM_API_KEY\n2. 使用其他功能模块(选股、回测、板块分析等)"
|
||||
}
|
||||
|
||||
session = get_or_create_session(session_id)
|
||||
|
||||
# 添加用户消息到历史
|
||||
session["messages"].append({"role": "user", "content": user_message})
|
||||
|
||||
# 意图识别 + Function Calling
|
||||
try:
|
||||
response = _process_message(session, user_message)
|
||||
|
||||
# 添加助手回复到历史
|
||||
session["messages"].append({"role": "assistant", "content": response["text"]})
|
||||
|
||||
# 限制历史长度(保留最近20轮)
|
||||
if len(session["messages"]) > 40:
|
||||
session["messages"] = session["messages"][-40:]
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": str(e),
|
||||
"text": f"处理消息时出错:{str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def _process_message(session: Dict, message: str) -> Dict[str, Any]:
|
||||
"""处理用户消息,识别意图并调用相应功能"""
|
||||
|
||||
# 构建系统提示
|
||||
system_prompt = """你是Blackdata股票分析助手,擅长A股分析和投资建议。
|
||||
|
||||
你可以调用以下功能(通过JSON格式返回):
|
||||
|
||||
1. 选股功能
|
||||
格式:{"action": "select_stocks", "conditions": {"涨幅": ">10", "量比": ">2", ...}, "description": "..."}
|
||||
示例:"帮我找近期突破的科技股" -> 识别为选股需求
|
||||
|
||||
2. 持仓诊断
|
||||
格式:{"action": "diagnose_portfolio"}
|
||||
示例:"我的持仓有什么风险?"
|
||||
|
||||
3. 策略建议
|
||||
格式:{"action": "strategy_advice"}
|
||||
示例:"当前市场适合什么策略?"
|
||||
|
||||
4. 个股分析
|
||||
格式:{"action": "analyze_stock", "code": "600519"}
|
||||
示例:"分析一下贵州茅台"
|
||||
|
||||
5. 板块分析
|
||||
格式:{"action": "analyze_sector", "name": "半导体"}
|
||||
示例:"半导体板块怎么样?"
|
||||
|
||||
6. 普通对话
|
||||
格式:{"action": "chat", "text": "..."}
|
||||
示例:闲聊、问候等
|
||||
|
||||
请根据用户问题,先判断意图,然后:
|
||||
- 如果需要调用功能,返回JSON格式的action
|
||||
- 如果是普通对话,直接回答
|
||||
|
||||
重要:
|
||||
- 如果用户问题包含"找"、"选"、"筛选"等词,考虑选股功能
|
||||
- 如果问"我的持仓"、"风险",调用持仓诊断
|
||||
- 如果问"策略"、"怎么操作",给策略建议
|
||||
- 股票代码格式:6位数字
|
||||
"""
|
||||
|
||||
# 构建对话历史
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
# 添加最近的对话历史(最多10轮)
|
||||
recent_messages = session["messages"][-20:] if len(session["messages"]) > 20 else session["messages"]
|
||||
messages.extend(recent_messages)
|
||||
|
||||
# 调用大模型
|
||||
try:
|
||||
response_text = llm.ask_with_messages(messages, temperature=0.7, max_tokens=1500)
|
||||
|
||||
# 尝试解析为JSON
|
||||
action = _parse_action(response_text)
|
||||
|
||||
if action:
|
||||
return _execute_action(action, session)
|
||||
else:
|
||||
# 纯文本回复
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "chat",
|
||||
"text": response_text
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"AI处理失败:{str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def _parse_action(text: str) -> Optional[Dict]:
|
||||
"""解析AI回复中的action"""
|
||||
try:
|
||||
# 查找JSON块
|
||||
if "{" in text and "}" in text:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}") + 1
|
||||
json_str = text[start:end]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _execute_action(action: Dict, session: Dict) -> Dict[str, Any]:
|
||||
"""执行具体功能"""
|
||||
|
||||
action_type = action.get("action")
|
||||
|
||||
if action_type == "select_stocks":
|
||||
return _handle_select_stocks(action, session)
|
||||
|
||||
elif action_type == "diagnose_portfolio":
|
||||
return _handle_diagnose_portfolio(session)
|
||||
|
||||
elif action_type == "strategy_advice":
|
||||
return _handle_strategy_advice(session)
|
||||
|
||||
elif action_type == "analyze_stock":
|
||||
return _handle_analyze_stock(action, session)
|
||||
|
||||
elif action_type == "analyze_sector":
|
||||
return _handle_analyze_sector(action, session)
|
||||
|
||||
elif action_type == "chat":
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "chat",
|
||||
"text": action.get("text", "我是Blackdata AI助手,有什么可以帮你?")
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": "抱歉,我不太理解您的问题。您可以问我:\n- 帮我选股\n- 我的持仓怎么样\n- 给我策略建议\n- 分析某个股票或板块"
|
||||
}
|
||||
|
||||
|
||||
def _handle_select_stocks(action: Dict, session: Dict) -> Dict[str, Any]:
|
||||
"""处理选股请求"""
|
||||
|
||||
# 从自然语言提取条件
|
||||
description = action.get("description", "")
|
||||
conditions = action.get("conditions", {})
|
||||
|
||||
# 构建选股策略
|
||||
strategy = selector.Strategy("AI选股", description)
|
||||
|
||||
# 将条件转换为选股条件
|
||||
for field, op_value in conditions.items():
|
||||
field_map = {
|
||||
"涨幅": "pct",
|
||||
"5日涨幅": "ret5",
|
||||
"20日涨幅": "ret20",
|
||||
"量比": "vol_ratio",
|
||||
"成交额": "amount",
|
||||
"RSI": "rsi14",
|
||||
"价格": "close"
|
||||
}
|
||||
|
||||
if field in field_map:
|
||||
actual_field = field_map[field]
|
||||
|
||||
# 解析操作符和值
|
||||
if isinstance(op_value, str):
|
||||
if op_value.startswith(">"):
|
||||
op = ">"
|
||||
val = float(op_value[1:].strip())
|
||||
elif op_value.startswith("<"):
|
||||
op = "<"
|
||||
val = float(op_value[1:].strip())
|
||||
else:
|
||||
continue
|
||||
|
||||
strategy.add_condition(actual_field, op, val)
|
||||
|
||||
# 如果没有条件,添加默认条件
|
||||
if not strategy.conditions:
|
||||
strategy.add_condition("ret5", ">", 5)
|
||||
strategy.add_condition("vol_ratio", ">", 1.5)
|
||||
|
||||
# 执行选股
|
||||
result = selector.run_selector(strategy)
|
||||
|
||||
if not result["ok"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"选股失败:{result.get('msg', '未知错误')}"
|
||||
}
|
||||
|
||||
# 保存选股结果到上下文
|
||||
session["context"]["last_selection"] = result["results"][:10]
|
||||
|
||||
# 格式化回复
|
||||
stocks = result["results"][:10]
|
||||
if not stocks:
|
||||
text = "根据您的条件,暂时没有找到符合的股票。您可以:\n1. 放宽筛选条件\n2. 尝试其他板块\n3. 等待市场出现机会"
|
||||
else:
|
||||
text = f"为您找到 {result['count']} 只股票,以下是前10只:\n\n"
|
||||
for i, s in enumerate(stocks, 1):
|
||||
text += f"{i}. {s['name']}({s['code']})\n"
|
||||
text += f" 现价:{s['close']}元 涨跌:{s['pct']:+.2f}% 5日:{s['ret5']:+.2f}%\n"
|
||||
text += f" 量比:{s['vol_ratio']:.2f} 成交额:{s['amount']:.1f}亿\n\n"
|
||||
|
||||
text += "💡 您可以继续问我:\n- 分析某只股票(如\"分析第1只\")\n- 回测这个策略\n- 看看其他板块"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "select_stocks",
|
||||
"text": text,
|
||||
"data": stocks
|
||||
}
|
||||
|
||||
|
||||
def _handle_diagnose_portfolio(session: Dict) -> Dict[str, Any]:
|
||||
"""处理持仓诊断"""
|
||||
|
||||
try:
|
||||
portfolio = pf.compute()
|
||||
|
||||
if not portfolio["holdings"]:
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "diagnose",
|
||||
"text": "您当前没有持仓。建议:\n1. 先在「交易日志」录入交易记录\n2. 或者问我\"帮我选股\"来寻找投资机会"
|
||||
}
|
||||
|
||||
summary = portfolio["summary"]
|
||||
holdings = portfolio["holdings"]
|
||||
|
||||
# 分析持仓
|
||||
total_unrealized = summary["unrealized"]
|
||||
win_rate = summary["win_rate"]
|
||||
|
||||
# 风险诊断
|
||||
risks = []
|
||||
|
||||
# 1. 浮亏检查
|
||||
losing_positions = [h for h in holdings if h["unrealized"] < 0]
|
||||
if len(losing_positions) > len(holdings) / 2:
|
||||
risks.append(f"⚠️ 超过一半的持仓处于浮亏状态({len(losing_positions)}/{len(holdings)}只)")
|
||||
|
||||
# 2. 集中度检查
|
||||
if len(holdings) < 3:
|
||||
risks.append("⚠️ 持仓过于集中,建议分散投资")
|
||||
|
||||
# 3. 胜率检查
|
||||
if win_rate < 40:
|
||||
risks.append(f"⚠️ 历史胜率较低({win_rate}%),建议反思选股策略")
|
||||
|
||||
# 构建回复
|
||||
text = f"📊 持仓诊断报告\n\n"
|
||||
text += f"持仓数量:{summary['positions']} 只\n"
|
||||
text += f"持仓市值:{summary['market_value']:.2f} 元\n"
|
||||
text += f"浮动盈亏:{total_unrealized:+.2f} 元\n"
|
||||
text += f"历史胜率:{win_rate}%\n\n"
|
||||
|
||||
if risks:
|
||||
text += "⚠️ 风险提示:\n"
|
||||
for risk in risks:
|
||||
text += f"{risk}\n"
|
||||
text += "\n"
|
||||
|
||||
# 前5大持仓
|
||||
text += "📈 前5大持仓:\n"
|
||||
for i, h in enumerate(holdings[:5], 1):
|
||||
pnl_sign = "+" if h["unrealized"] >= 0 else ""
|
||||
text += f"{i}. {h['name']} {pnl_sign}{h['unrealized_pct']:.2f}% {pnl_sign}{h['unrealized']:.0f}元\n"
|
||||
|
||||
text += "\n💡 建议:\n"
|
||||
if risks:
|
||||
text += "- 考虑止损浮亏较大的股票\n"
|
||||
text += "- 增加持仓分散度\n"
|
||||
else:
|
||||
text += "- 当前持仓状况良好,继续关注\n"
|
||||
text += "- 定期复盘,总结经验\n"
|
||||
|
||||
# 保存到上下文
|
||||
session["context"]["portfolio"] = holdings
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "diagnose",
|
||||
"text": text,
|
||||
"data": portfolio
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"持仓诊断失败:{str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def _handle_strategy_advice(session: Dict) -> Dict[str, Any]:
|
||||
"""处理策略建议"""
|
||||
|
||||
try:
|
||||
# 获取市场情绪
|
||||
summary = sector.get_rotation_summary()
|
||||
|
||||
if not summary.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"text": "暂时无法获取市场数据,请稍后再试"
|
||||
}
|
||||
|
||||
strongest = summary.get("strongest_sectors", [])
|
||||
weakest = summary.get("weakest_sectors", [])
|
||||
|
||||
# 构建策略建议
|
||||
text = "📋 当前市场策略建议\n\n"
|
||||
|
||||
text += "🔥 强势板块:\n"
|
||||
for s in strongest[:3]:
|
||||
text += f"- {s['name']} {s['return_10d']:+.2f}%\n"
|
||||
text += "\n"
|
||||
|
||||
text += "📉 弱势板块:\n"
|
||||
for s in weakest[:3]:
|
||||
text += f"- {s['name']} {s['return_10d']:+.2f}%\n"
|
||||
text += "\n"
|
||||
|
||||
# 策略建议
|
||||
avg_return = sum(s['return_10d'] for s in strongest[:3]) / 3 if strongest else 0
|
||||
|
||||
if avg_return > 10:
|
||||
text += "💡 策略建议:\n"
|
||||
text += "- 市场情绪较好,适合进攻型策略\n"
|
||||
text += "- 可关注强势板块的龙头股\n"
|
||||
text += "- 设置好止盈点,及时落袋为安\n"
|
||||
elif avg_return > 0:
|
||||
text += "💡 策略建议:\n"
|
||||
text += "- 市场震荡,适合波段操作\n"
|
||||
text += "- 追踪强势板块,低吸高抛\n"
|
||||
text += "- 控制仓位,分批建仓\n"
|
||||
else:
|
||||
text += "💡 策略建议:\n"
|
||||
text += "- 市场偏弱,以防守为主\n"
|
||||
text += "- 减仓观望,等待机会\n"
|
||||
text += "- 关注超跌板块的反弹机会\n"
|
||||
|
||||
text += "\n🎯 具体操作:\n"
|
||||
text += "- 可以问我\"帮我找[强势板块]的股票\"\n"
|
||||
text += "- 或\"分析[某个板块]\"\n"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "strategy",
|
||||
"text": text,
|
||||
"data": summary
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"策略建议失败:{str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def _handle_analyze_stock(action: Dict, session: Dict) -> Dict[str, Any]:
|
||||
"""处理个股分析"""
|
||||
|
||||
code = action.get("code", "").strip()
|
||||
|
||||
if not code:
|
||||
# 从上下文中获取
|
||||
last_selection = session["context"].get("last_selection", [])
|
||||
if last_selection:
|
||||
code = last_selection[0]["code"]
|
||||
else:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": "请指定股票代码,例如\"分析600519\""
|
||||
}
|
||||
|
||||
try:
|
||||
result = ai.diagnose(code)
|
||||
|
||||
if not result["ok"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"分析失败:{result.get('msg', '未知错误')}"
|
||||
}
|
||||
|
||||
# 格式化回复
|
||||
text = f"📊 {result['name']}({result['symbol']})AI诊断\n\n"
|
||||
text += f"综合评分:{result['total']}分\n"
|
||||
text += f"预测方向:{'看多' if result['direction'] == 'up' else ('看空' if result['direction'] == 'down' else '中性')}\n"
|
||||
text += f"置信度:{result['confidence']}%\n\n"
|
||||
|
||||
text += "📈 各维度评分:\n"
|
||||
for dim, score in result["scores"].items():
|
||||
text += f"- {dim}:{score}分\n"
|
||||
|
||||
text += f"\n💬 {result['text'][:300]}...\n"
|
||||
text += "\n💡 完整分析请在「AI分析 → 个股诊断」页面查看"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "analyze_stock",
|
||||
"text": text,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"分析失败:{str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def _handle_analyze_sector(action: Dict, session: Dict) -> Dict[str, Any]:
|
||||
"""处理板块分析"""
|
||||
|
||||
sector_name = action.get("name", "").strip()
|
||||
|
||||
if not sector_name:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": "请指定板块名称,例如\"分析半导体板块\""
|
||||
}
|
||||
|
||||
try:
|
||||
result = sector.analyze_lifecycle(sector_name, days=60)
|
||||
|
||||
if not result["ok"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"分析失败:{result.get('msg', '未知错误')}"
|
||||
}
|
||||
|
||||
# 格式化回复
|
||||
text = f"📊 {result['sector']} 板块分析\n\n"
|
||||
text += f"生命周期:{result['phase']}\n"
|
||||
text += f"{result['description']}\n\n"
|
||||
|
||||
metrics = result["metrics"]
|
||||
text += f"📈 近期表现:\n"
|
||||
text += f"- 5日涨幅:{metrics['return_5d']:+.2f}%\n"
|
||||
text += f"- 20日涨幅:{metrics['return_20d']:+.2f}%\n"
|
||||
text += f"- 成交额变化:{metrics['amount_change']:+.2f}%\n\n"
|
||||
|
||||
# 龙头股
|
||||
leaders = sector.identify_leaders(sector_name, limit=5)
|
||||
if leaders["ok"] and leaders["leaders"]:
|
||||
text += "🏆 龙头股:\n"
|
||||
for i, l in enumerate(leaders["leaders"][:3], 1):
|
||||
text += f"{i}. {l['name']} {l['ret20']:+.2f}%\n"
|
||||
|
||||
text += "\n💡 您可以继续问:\n"
|
||||
text += f"- 帮我找{sector_name}板块的股票\n"
|
||||
text += f"- {sector_name}龙头股有哪些\n"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "analyze_sector",
|
||||
"text": text,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"text": f"分析失败:{str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def clear_session(session_id: str):
|
||||
"""清空会话"""
|
||||
if session_id in _SESSIONS:
|
||||
del _SESSIONS[session_id]
|
||||
|
||||
|
||||
def get_session_history(session_id: str) -> List[Dict]:
|
||||
"""获取会话历史"""
|
||||
session = _SESSIONS.get(session_id)
|
||||
if session:
|
||||
return session["messages"]
|
||||
return []
|
||||
@@ -60,7 +60,7 @@ def check_alerts():
|
||||
# 触发后向已配置渠道推送(站外)
|
||||
if push_msgs and notifier.any_enabled():
|
||||
try:
|
||||
notifier.notify("【智策预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"),
|
||||
notifier.notify("【Blackdata预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"),
|
||||
"\n".join(push_msgs))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
437
backend/attribution_analysis.py
Normal file
437
backend/attribution_analysis.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""持仓归因分析深化 — 选股/择时能力、持仓时长、理由有效性分析。
|
||||
|
||||
功能:
|
||||
1. 收益归因分解(选股 vs 择时 vs 运气)
|
||||
2. 持仓时长分析(短线/中线/长线胜率)
|
||||
3. 买入理由有效性验证
|
||||
4. 情绪标签相关性分析
|
||||
5. 对标指数超额收益拆解
|
||||
"""
|
||||
import datetime as dt
|
||||
from typing import Dict, List, Any, Tuple
|
||||
from collections import defaultdict
|
||||
import numpy as np
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from db import get_session
|
||||
from models import Trade, DailyQuote, IndexDaily, StockMetric
|
||||
|
||||
|
||||
def analyze_attribution() -> Dict[str, Any]:
|
||||
"""综合归因分析"""
|
||||
with get_session() as s:
|
||||
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
|
||||
|
||||
if not trades:
|
||||
return {"ok": False, "msg": "暂无交易记录"}
|
||||
|
||||
stock_timing = analyze_stock_vs_timing(trades)
|
||||
hold_period = analyze_hold_period(trades)
|
||||
reason_valid = analyze_reason_validity(trades)
|
||||
emotion_corr = analyze_emotion_correlation(trades)
|
||||
excess_return = analyze_excess_return(trades)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"stock_vs_timing": stock_timing,
|
||||
"hold_period": hold_period,
|
||||
"reason_validity": reason_valid,
|
||||
"emotion_correlation": emotion_corr,
|
||||
"excess_return": excess_return,
|
||||
}
|
||||
|
||||
|
||||
def analyze_stock_vs_timing(trades: List[Trade]) -> Dict[str, Any]:
|
||||
"""分解选股能力 vs 择时能力
|
||||
|
||||
选股能力:买入后股票的整体涨幅(持有期间市场表现)
|
||||
择时能力:实际买卖点的精准度(买在低点、卖在高点)
|
||||
运气成分:市场整体波动的影响
|
||||
"""
|
||||
with get_session() as s:
|
||||
stock_trades = defaultdict(list)
|
||||
for t in trades:
|
||||
stock_trades[t.code].append(t)
|
||||
|
||||
results = []
|
||||
total_stock_contrib = 0.0
|
||||
total_timing_contrib = 0.0
|
||||
|
||||
for code, stock_trades_list in stock_trades.items():
|
||||
dates = [t.date for t in stock_trades_list]
|
||||
start = min(dates)
|
||||
end = max(dates)
|
||||
|
||||
prices = {}
|
||||
for d, close in s.execute(
|
||||
select(DailyQuote.date, DailyQuote.close)
|
||||
.where(
|
||||
and_(
|
||||
DailyQuote.code == code,
|
||||
DailyQuote.date >= start,
|
||||
DailyQuote.date <= end,
|
||||
)
|
||||
)
|
||||
.order_by(DailyQuote.date)
|
||||
).all():
|
||||
prices[d] = float(close)
|
||||
|
||||
if not prices:
|
||||
continue
|
||||
|
||||
first_price = prices[min(prices.keys())]
|
||||
last_price = prices[max(prices.keys())]
|
||||
stock_return = (last_price / first_price - 1) * 100
|
||||
|
||||
buys = [t for t in stock_trades_list if t.side == "buy"]
|
||||
sells = [t for t in stock_trades_list if t.side == "sell"]
|
||||
|
||||
if buys and sells:
|
||||
avg_buy = np.mean([t.price for t in buys])
|
||||
avg_sell = np.mean([t.price for t in sells])
|
||||
|
||||
ideal_buy = min(prices.values())
|
||||
ideal_sell = max(prices.values())
|
||||
|
||||
buy_timing = (
|
||||
(1 - (avg_buy - ideal_buy) / (ideal_sell - ideal_buy)) * 100
|
||||
if ideal_sell > ideal_buy
|
||||
else 50
|
||||
)
|
||||
sell_timing = (
|
||||
((avg_sell - ideal_buy) / (ideal_sell - ideal_buy)) * 100
|
||||
if ideal_sell > ideal_buy
|
||||
else 50
|
||||
)
|
||||
timing_score = (buy_timing + sell_timing) / 2
|
||||
actual_return = (avg_sell / avg_buy - 1) * 100
|
||||
|
||||
results.append(
|
||||
{
|
||||
"code": code,
|
||||
"stock_return": round(stock_return, 2),
|
||||
"timing_score": round(timing_score, 1),
|
||||
"actual_return": round(actual_return, 2),
|
||||
}
|
||||
)
|
||||
|
||||
total_stock_contrib += stock_return
|
||||
total_timing_contrib += timing_score
|
||||
|
||||
if not results:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
avg_stock = total_stock_contrib / len(results)
|
||||
avg_timing = total_timing_contrib / len(results)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"stock_ability": round(avg_stock, 2),
|
||||
"timing_ability": round(avg_timing, 1),
|
||||
"interpretation": {
|
||||
"stock": "正值表示选对了股票(股票整体上涨),负值表示选错了",
|
||||
"timing": "100分满分,表示买卖点的精准度,50分为平均水平",
|
||||
},
|
||||
"by_stock": results,
|
||||
}
|
||||
|
||||
|
||||
def analyze_hold_period(trades: List[Trade]) -> Dict[str, Any]:
|
||||
"""持仓时长分析
|
||||
|
||||
短线:持仓 <= 5天
|
||||
中线:持仓 6-30天
|
||||
长线:持仓 > 30天
|
||||
"""
|
||||
holdings = defaultdict(list)
|
||||
closed_trades = []
|
||||
|
||||
for t in trades:
|
||||
if t.side == "buy":
|
||||
holdings[t.code].append({"trade": t, "qty": t.qty})
|
||||
else:
|
||||
remaining = t.qty
|
||||
while remaining > 0 and holdings[t.code]:
|
||||
hold = holdings[t.code][0]
|
||||
sell_qty = min(remaining, hold["qty"])
|
||||
|
||||
hold_days = (t.date - hold["trade"].date).days
|
||||
pnl = (t.price - hold["trade"].price) * sell_qty - t.fee * (
|
||||
sell_qty / t.qty
|
||||
)
|
||||
pnl_pct = (t.price / hold["trade"].price - 1) * 100
|
||||
|
||||
closed_trades.append(
|
||||
{
|
||||
"code": t.code,
|
||||
"buy_date": hold["trade"].date,
|
||||
"sell_date": t.date,
|
||||
"hold_days": hold_days,
|
||||
"buy_price": hold["trade"].price,
|
||||
"sell_price": t.price,
|
||||
"qty": sell_qty,
|
||||
"pnl": pnl,
|
||||
"pnl_pct": pnl_pct,
|
||||
}
|
||||
)
|
||||
|
||||
hold["qty"] -= sell_qty
|
||||
remaining -= sell_qty
|
||||
|
||||
if hold["qty"] <= 0:
|
||||
holdings[t.code].pop(0)
|
||||
|
||||
if not closed_trades:
|
||||
return {"ok": False, "msg": "暂无已平仓交易"}
|
||||
|
||||
short_term = [t for t in closed_trades if t["hold_days"] <= 5]
|
||||
mid_term = [t for t in closed_trades if 6 <= t["hold_days"] <= 30]
|
||||
long_term = [t for t in closed_trades if t["hold_days"] > 30]
|
||||
|
||||
def calc_stats(trades_list):
|
||||
if not trades_list:
|
||||
return {"count": 0, "win_rate": 0, "avg_return": 0, "avg_days": 0}
|
||||
wins = sum(1 for t in trades_list if t["pnl"] > 0)
|
||||
return {
|
||||
"count": len(trades_list),
|
||||
"win_rate": round(wins / len(trades_list) * 100, 1),
|
||||
"avg_return": round(np.mean([t["pnl_pct"] for t in trades_list]), 2),
|
||||
"avg_days": round(np.mean([t["hold_days"] for t in trades_list]), 1),
|
||||
"total_pnl": round(sum(t["pnl"] for t in trades_list), 2),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"short_term": calc_stats(short_term),
|
||||
"mid_term": calc_stats(mid_term),
|
||||
"long_term": calc_stats(long_term),
|
||||
"recommendation": _recommend_hold_period(short_term, mid_term, long_term),
|
||||
}
|
||||
|
||||
|
||||
def _recommend_hold_period(short, mid, long) -> str:
|
||||
"""推荐最佳持仓周期"""
|
||||
periods = [
|
||||
("短线(≤5天)", short),
|
||||
("中线(6-30天)", mid),
|
||||
("长线(>30天)", long),
|
||||
]
|
||||
|
||||
if not any(p for _, p in periods):
|
||||
return "数据不足"
|
||||
|
||||
scores = []
|
||||
for name, trades_list in periods:
|
||||
if not trades_list:
|
||||
scores.append((name, 0))
|
||||
continue
|
||||
wins = sum(1 for t in trades_list if t["pnl"] > 0)
|
||||
win_rate = wins / len(trades_list)
|
||||
avg_ret = np.mean([t["pnl_pct"] for t in trades_list])
|
||||
score = win_rate * 50 + (avg_ret / 10) * 50
|
||||
scores.append((name, score))
|
||||
|
||||
scores.sort(key=lambda x: x[1], reverse=True)
|
||||
best = scores[0][0]
|
||||
|
||||
return f"建议重点关注{best},该周期胜率和收益表现最佳"
|
||||
|
||||
|
||||
def analyze_reason_validity(trades: List[Trade]) -> Dict[str, Any]:
|
||||
"""买入理由有效性验证"""
|
||||
holdings = defaultdict(list)
|
||||
reason_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0})
|
||||
|
||||
for t in trades:
|
||||
if t.side == "buy":
|
||||
holdings[t.code].append(t)
|
||||
else:
|
||||
while t.qty > 0 and holdings[t.code]:
|
||||
buy = holdings[t.code].pop(0)
|
||||
qty = min(t.qty, buy.qty)
|
||||
|
||||
pnl = (t.price - buy.price) * qty
|
||||
pnl_pct = (t.price / buy.price - 1) * 100
|
||||
|
||||
reason = buy.reason or "未标注"
|
||||
reason_stats[reason]["trades"].append(pnl_pct)
|
||||
reason_stats[reason]["total_pnl"] += pnl
|
||||
if pnl > 0:
|
||||
reason_stats[reason]["wins"] += 1
|
||||
|
||||
buy.qty -= qty
|
||||
t.qty -= qty
|
||||
if buy.qty > 0:
|
||||
holdings[t.code].insert(0, buy)
|
||||
|
||||
if not reason_stats:
|
||||
return {"ok": False, "msg": "暂无已平仓交易"}
|
||||
|
||||
results = []
|
||||
for reason, stats in reason_stats.items():
|
||||
trades_list = stats["trades"]
|
||||
results.append(
|
||||
{
|
||||
"reason": reason,
|
||||
"count": len(trades_list),
|
||||
"win_rate": round(stats["wins"] / len(trades_list) * 100, 1),
|
||||
"avg_return": round(np.mean(trades_list), 2),
|
||||
"total_pnl": round(stats["total_pnl"], 2),
|
||||
"effectiveness": "有效"
|
||||
if stats["wins"] / len(trades_list) > 0.5
|
||||
else "无效",
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(key=lambda x: x["win_rate"], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"by_reason": results,
|
||||
"best_reason": results[0]["reason"] if results else None,
|
||||
"worst_reason": results[-1]["reason"] if results else None,
|
||||
}
|
||||
|
||||
|
||||
def analyze_emotion_correlation(trades: List[Trade]) -> Dict[str, Any]:
|
||||
"""情绪标签相关性分析"""
|
||||
holdings = defaultdict(list)
|
||||
emotion_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0})
|
||||
|
||||
for t in trades:
|
||||
if t.side == "buy":
|
||||
holdings[t.code].append(t)
|
||||
else:
|
||||
while t.qty > 0 and holdings[t.code]:
|
||||
buy = holdings[t.code].pop(0)
|
||||
qty = min(t.qty, buy.qty)
|
||||
|
||||
pnl = (t.price - buy.price) * qty
|
||||
pnl_pct = (t.price / buy.price - 1) * 100
|
||||
|
||||
emotion = buy.emotion or "未标注"
|
||||
emotion_stats[emotion]["trades"].append(pnl_pct)
|
||||
emotion_stats[emotion]["total_pnl"] += pnl
|
||||
if pnl > 0:
|
||||
emotion_stats[emotion]["wins"] += 1
|
||||
|
||||
buy.qty -= qty
|
||||
t.qty -= qty
|
||||
if buy.qty > 0:
|
||||
holdings[t.code].insert(0, buy)
|
||||
|
||||
if not emotion_stats:
|
||||
return {"ok": False, "msg": "暂无已平仓交易"}
|
||||
|
||||
results = []
|
||||
for emotion, stats in emotion_stats.items():
|
||||
trades_list = stats["trades"]
|
||||
results.append(
|
||||
{
|
||||
"emotion": emotion,
|
||||
"count": len(trades_list),
|
||||
"win_rate": round(stats["wins"] / len(trades_list) * 100, 1),
|
||||
"avg_return": round(np.mean(trades_list), 2),
|
||||
"total_pnl": round(stats["total_pnl"], 2),
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(key=lambda x: x["avg_return"], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"by_emotion": results,
|
||||
"advice": _generate_emotion_advice(results),
|
||||
}
|
||||
|
||||
|
||||
def _generate_emotion_advice(results: List[Dict]) -> str:
|
||||
"""生成情绪建议"""
|
||||
if not results:
|
||||
return "数据不足"
|
||||
|
||||
best = results[0]
|
||||
worst = results[-1]
|
||||
|
||||
advice = (
|
||||
f"最佳情绪状态:{best['emotion']}(胜率{best['win_rate']}%,"
|
||||
f"平均收益{best['avg_return']}%)\n"
|
||||
)
|
||||
advice += (
|
||||
f"最差情绪状态:{worst['emotion']}(胜率{worst['win_rate']}%,"
|
||||
f"平均收益{worst['avg_return']}%)\n"
|
||||
)
|
||||
advice += "\n建议:保持理性和纪律,避免在贪婪或恐慌时做决策"
|
||||
|
||||
return advice
|
||||
|
||||
|
||||
def analyze_excess_return(trades: List[Trade]) -> Dict[str, Any]:
|
||||
"""对标指数超额收益拆解"""
|
||||
if not trades:
|
||||
return {"ok": False, "msg": "暂无交易记录"}
|
||||
|
||||
start_date = min(t.date for t in trades)
|
||||
end_date = max(t.date for t in trades)
|
||||
|
||||
with get_session() as s:
|
||||
index_data = s.execute(
|
||||
select(IndexDaily.date, IndexDaily.close)
|
||||
.where(
|
||||
and_(
|
||||
IndexDaily.code == "sh000300",
|
||||
IndexDaily.date >= start_date,
|
||||
IndexDaily.date <= end_date,
|
||||
)
|
||||
)
|
||||
.order_by(IndexDaily.date)
|
||||
).all()
|
||||
|
||||
if not index_data:
|
||||
return {"ok": False, "msg": "缺少指数数据"}
|
||||
|
||||
index_start = float(index_data[0][1])
|
||||
index_end = float(index_data[-1][1])
|
||||
index_return = (index_end / index_start - 1) * 100
|
||||
|
||||
holdings = defaultdict(lambda: {"qty": 0, "cost": 0.0})
|
||||
realized_pnl = 0.0
|
||||
total_cost = 0.0
|
||||
|
||||
for t in trades:
|
||||
p = holdings[t.code]
|
||||
if t.side == "buy":
|
||||
p["cost"] += t.price * t.qty + t.fee
|
||||
p["qty"] += t.qty
|
||||
total_cost += t.price * t.qty + t.fee
|
||||
else:
|
||||
if p["qty"] > 0:
|
||||
avg = p["cost"] / p["qty"]
|
||||
qty = min(t.qty, p["qty"])
|
||||
pnl = (t.price - avg) * qty - t.fee
|
||||
realized_pnl += pnl
|
||||
p["cost"] -= avg * qty
|
||||
p["qty"] -= qty
|
||||
|
||||
portfolio_return = (realized_pnl / total_cost * 100) if total_cost > 0 else 0
|
||||
excess_return = portfolio_return - index_return
|
||||
|
||||
if excess_return > 0:
|
||||
source = "选股能力贡献"
|
||||
interpretation = "组合表现优于大盘,说明选股和择时能力较好"
|
||||
elif excess_return < -5:
|
||||
source = "选股/择时失误"
|
||||
interpretation = "组合表现明显弱于大盘,建议反思选股逻辑和买卖时机"
|
||||
else:
|
||||
source = "与大盘持平"
|
||||
interpretation = "组合表现与大盘接近,可考虑增强选股策略"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"portfolio_return": round(portfolio_return, 2),
|
||||
"index_return": round(index_return, 2),
|
||||
"excess_return": round(excess_return, 2),
|
||||
"source": source,
|
||||
"interpretation": interpretation,
|
||||
"period": f"{start_date} ~ {end_date}",
|
||||
}
|
||||
499
backend/backtest_advanced.py
Normal file
499
backend/backtest_advanced.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""增强版回测引擎 — 多因子策略、仓位管理、参数优化。
|
||||
|
||||
支持功能:
|
||||
1. 多因子组合策略(技术+基本面)
|
||||
2. 仓位管理(固定、金字塔、凯利公式)
|
||||
3. 止损止盈
|
||||
4. 参数网格优化
|
||||
5. 完整指标(夏普比率、最大回撤、卡玛比率等)
|
||||
6. 交易明细导出
|
||||
"""
|
||||
import datetime as dt
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
import numpy as np
|
||||
from sqlalchemy import select
|
||||
|
||||
from db import get_session
|
||||
from models import DailyQuote, StockMetric
|
||||
|
||||
|
||||
class Position:
|
||||
"""持仓记录"""
|
||||
def __init__(self, date, price, shares, reason=""):
|
||||
self.entry_date = date
|
||||
self.entry_price = price
|
||||
self.shares = shares
|
||||
self.reason = reason
|
||||
self.exit_date = None
|
||||
self.exit_price = None
|
||||
self.pnl = 0.0
|
||||
self.pnl_pct = 0.0
|
||||
self.hold_days = 0
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
"""增强回测引擎"""
|
||||
|
||||
def __init__(self, initial_capital: float = 100000.0, commission: float = 0.0005):
|
||||
self.initial_capital = initial_capital
|
||||
self.commission = commission
|
||||
|
||||
# 账户状态
|
||||
self.cash = initial_capital
|
||||
self.positions: List[Position] = []
|
||||
self.closed_positions: List[Position] = []
|
||||
|
||||
# 净值曲线
|
||||
self.equity_curve = []
|
||||
self.dates = []
|
||||
|
||||
# 统计
|
||||
self.trades = 0
|
||||
self.wins = 0
|
||||
self.total_pnl = 0.0
|
||||
|
||||
def get_position_value(self, price: float) -> float:
|
||||
"""计算持仓市值"""
|
||||
return sum(p.shares * price for p in self.positions)
|
||||
|
||||
def get_total_value(self, price: float) -> float:
|
||||
"""计算总资产"""
|
||||
return self.cash + self.get_position_value(price)
|
||||
|
||||
def buy(self, date, price: float, size: float, reason: str = ""):
|
||||
"""买入
|
||||
|
||||
Args:
|
||||
date: 交易日期
|
||||
price: 买入价格
|
||||
size: 仓位大小(0-1),相对于当前可用资金
|
||||
reason: 买入理由
|
||||
"""
|
||||
if size <= 0 or size > 1:
|
||||
return False
|
||||
|
||||
cost = self.cash * size
|
||||
commission_fee = cost * self.commission
|
||||
net_cost = cost - commission_fee
|
||||
|
||||
if net_cost <= 0:
|
||||
return False
|
||||
|
||||
shares = net_cost / price
|
||||
self.cash -= cost
|
||||
|
||||
pos = Position(date, price, shares, reason)
|
||||
self.positions.append(pos)
|
||||
self.trades += 1
|
||||
return True
|
||||
|
||||
def sell(self, date, price: float, size: float = 1.0, reason: str = ""):
|
||||
"""卖出
|
||||
|
||||
Args:
|
||||
date: 交易日期
|
||||
price: 卖出价格
|
||||
size: 卖出比例(0-1),相对于持仓
|
||||
reason: 卖出理由
|
||||
"""
|
||||
if not self.positions or size <= 0 or size > 1:
|
||||
return False
|
||||
|
||||
# 按先进先出卖出
|
||||
remaining = size
|
||||
sold_positions = []
|
||||
|
||||
for pos in self.positions[:]:
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
sell_ratio = min(remaining, 1.0)
|
||||
sell_shares = pos.shares * sell_ratio
|
||||
proceeds = sell_shares * price
|
||||
commission_fee = proceeds * self.commission
|
||||
net_proceeds = proceeds - commission_fee
|
||||
|
||||
self.cash += net_proceeds
|
||||
|
||||
# 更新持仓
|
||||
pos.shares -= sell_shares
|
||||
if pos.shares < 0.01: # 清仓
|
||||
pos.exit_date = date
|
||||
pos.exit_price = price
|
||||
pos.hold_days = (date - pos.entry_date).days
|
||||
pos.pnl = (price - pos.entry_price) * (sell_shares / sell_ratio)
|
||||
pos.pnl_pct = (price / pos.entry_price - 1) * 100
|
||||
|
||||
self.closed_positions.append(pos)
|
||||
self.positions.remove(pos)
|
||||
|
||||
if pos.pnl > 0:
|
||||
self.wins += 1
|
||||
self.total_pnl += pos.pnl
|
||||
|
||||
remaining -= sell_ratio
|
||||
sold_positions.append((pos, sell_shares))
|
||||
|
||||
return True
|
||||
|
||||
def record_state(self, date, price: float):
|
||||
"""记录当前状态"""
|
||||
self.dates.append(date)
|
||||
self.equity_curve.append(self.get_total_value(price))
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
"""计算完整指标"""
|
||||
if not self.equity_curve:
|
||||
return {}
|
||||
|
||||
equity = np.array(self.equity_curve)
|
||||
returns = np.diff(equity) / equity[:-1]
|
||||
|
||||
# 基础指标
|
||||
total_return = (equity[-1] / equity[0] - 1) * 100
|
||||
|
||||
# 最大回撤
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
max_drawdown = np.max(drawdown) * 100
|
||||
|
||||
# 夏普比率(年化,假设252个交易日)
|
||||
if len(returns) > 1 and np.std(returns) > 0:
|
||||
sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
# 卡玛比率(收益/最大回撤)
|
||||
calmar = total_return / max_drawdown if max_drawdown > 0 else 0.0
|
||||
|
||||
# 胜率
|
||||
closed = len(self.closed_positions)
|
||||
win_rate = (self.wins / closed * 100) if closed > 0 else 0.0
|
||||
|
||||
# 盈亏比
|
||||
winning_trades = [p.pnl for p in self.closed_positions if p.pnl > 0]
|
||||
losing_trades = [abs(p.pnl) for p in self.closed_positions if p.pnl < 0]
|
||||
avg_win = np.mean(winning_trades) if winning_trades else 0.0
|
||||
avg_loss = np.mean(losing_trades) if losing_trades else 0.0
|
||||
profit_factor = avg_win / avg_loss if avg_loss > 0 else 0.0
|
||||
|
||||
# 持仓天数
|
||||
hold_days = [p.hold_days for p in self.closed_positions]
|
||||
avg_hold = np.mean(hold_days) if hold_days else 0.0
|
||||
|
||||
return {
|
||||
"total_return": round(total_return, 2),
|
||||
"max_drawdown": round(max_drawdown, 2),
|
||||
"sharpe_ratio": round(sharpe, 3),
|
||||
"calmar_ratio": round(calmar, 3),
|
||||
"trades": self.trades,
|
||||
"closed_trades": closed,
|
||||
"win_rate": round(win_rate, 1),
|
||||
"profit_factor": round(profit_factor, 2),
|
||||
"avg_win": round(avg_win, 2),
|
||||
"avg_loss": round(avg_loss, 2),
|
||||
"avg_hold_days": round(avg_hold, 1),
|
||||
"total_pnl": round(self.total_pnl, 2),
|
||||
}
|
||||
|
||||
|
||||
class Strategy:
|
||||
"""策略基类"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None:
|
||||
"""每日回调"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MAStrategy(Strategy):
|
||||
"""均线交叉策略(增强版)"""
|
||||
|
||||
def __init__(self, fast: int = 5, slow: int = 20,
|
||||
position_size: float = 1.0,
|
||||
stop_loss: float = 0.0,
|
||||
take_profit: float = 0.0):
|
||||
super().__init__(f"MA{fast}/{slow}")
|
||||
self.fast = fast
|
||||
self.slow = slow
|
||||
self.position_size = position_size
|
||||
self.stop_loss = stop_loss # 止损比例
|
||||
self.take_profit = take_profit # 止盈比例
|
||||
|
||||
self.ma_fast_history = []
|
||||
self.ma_slow_history = []
|
||||
self.close_history = []
|
||||
|
||||
def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None:
|
||||
close = data["close"]
|
||||
self.close_history.append(close)
|
||||
|
||||
# 计算均线
|
||||
if len(self.close_history) >= self.fast:
|
||||
self.ma_fast_history.append(np.mean(self.close_history[-self.fast:]))
|
||||
else:
|
||||
self.ma_fast_history.append(None)
|
||||
|
||||
if len(self.close_history) >= self.slow:
|
||||
self.ma_slow_history.append(np.mean(self.close_history[-self.slow:]))
|
||||
else:
|
||||
self.ma_slow_history.append(None)
|
||||
|
||||
if len(self.ma_fast_history) < 2:
|
||||
engine.record_state(date, close)
|
||||
return
|
||||
|
||||
maf_curr = self.ma_fast_history[-1]
|
||||
maf_prev = self.ma_fast_history[-2]
|
||||
mas_curr = self.ma_slow_history[-1]
|
||||
mas_prev = self.ma_slow_history[-2]
|
||||
|
||||
if maf_curr is None or mas_curr is None:
|
||||
engine.record_state(date, close)
|
||||
return
|
||||
|
||||
# 止损止盈检查
|
||||
if engine.positions:
|
||||
for pos in engine.positions[:]:
|
||||
pnl_pct = (close / pos.entry_price - 1) * 100
|
||||
|
||||
# 止损
|
||||
if self.stop_loss > 0 and pnl_pct <= -self.stop_loss:
|
||||
engine.sell(date, close, 1.0, f"止损 {pnl_pct:.2f}%")
|
||||
# 止盈
|
||||
elif self.take_profit > 0 and pnl_pct >= self.take_profit:
|
||||
engine.sell(date, close, 1.0, f"止盈 {pnl_pct:.2f}%")
|
||||
|
||||
# 金叉买入
|
||||
if maf_prev <= mas_prev and maf_curr > mas_curr:
|
||||
if not engine.positions:
|
||||
engine.buy(date, close, self.position_size, "金叉")
|
||||
|
||||
# 死叉卖出
|
||||
elif maf_prev >= mas_prev and maf_curr < mas_curr:
|
||||
if engine.positions:
|
||||
engine.sell(date, close, 1.0, "死叉")
|
||||
|
||||
engine.record_state(date, close)
|
||||
|
||||
|
||||
class MultiFactorStrategy(Strategy):
|
||||
"""多因子策略"""
|
||||
|
||||
def __init__(self, position_size: float = 1.0):
|
||||
super().__init__("多因子")
|
||||
self.position_size = position_size
|
||||
self.close_history = []
|
||||
self.volume_history = []
|
||||
|
||||
def calculate_rsi(self, n: int = 14) -> Optional[float]:
|
||||
"""计算RSI"""
|
||||
if len(self.close_history) < n + 1:
|
||||
return None
|
||||
|
||||
changes = np.diff(self.close_history[-n-1:])
|
||||
gains = np.where(changes > 0, changes, 0)
|
||||
losses = np.where(changes < 0, -changes, 0)
|
||||
|
||||
avg_gain = np.mean(gains)
|
||||
avg_loss = np.mean(losses)
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
|
||||
rs = avg_gain / avg_loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return rsi
|
||||
|
||||
def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None:
|
||||
close = data["close"]
|
||||
volume = data.get("volume", 0)
|
||||
|
||||
self.close_history.append(close)
|
||||
self.volume_history.append(volume)
|
||||
|
||||
if len(self.close_history) < 30:
|
||||
engine.record_state(date, close)
|
||||
return
|
||||
|
||||
# 计算因子
|
||||
ma5 = np.mean(self.close_history[-5:])
|
||||
ma20 = np.mean(self.close_history[-20:])
|
||||
rsi = self.calculate_rsi(14)
|
||||
|
||||
# 量比
|
||||
vol_avg = np.mean(self.volume_history[-20:-1])
|
||||
vol_ratio = volume / vol_avg if vol_avg > 0 else 1.0
|
||||
|
||||
# 买入信号:MA5 > MA20, RSI < 70, 放量
|
||||
buy_signal = (ma5 > ma20 and
|
||||
rsi is not None and rsi < 70 and
|
||||
vol_ratio > 1.5)
|
||||
|
||||
# 卖出信号:MA5 < MA20 或 RSI > 80
|
||||
sell_signal = (ma5 < ma20 or
|
||||
(rsi is not None and rsi > 80))
|
||||
|
||||
if buy_signal and not engine.positions:
|
||||
engine.buy(date, close, self.position_size, "多因子买入")
|
||||
|
||||
if sell_signal and engine.positions:
|
||||
engine.sell(date, close, 1.0, "多因子卖出")
|
||||
|
||||
engine.record_state(date, close)
|
||||
|
||||
|
||||
def run_advanced_backtest(symbol: str,
|
||||
strategy: Strategy,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
initial_capital: float = 100000.0,
|
||||
commission: float = 0.0005) -> Dict[str, Any]:
|
||||
"""运行增强回测
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
strategy: 策略实例
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
initial_capital: 初始资金
|
||||
commission: 手续费率
|
||||
|
||||
Returns:
|
||||
回测结果
|
||||
"""
|
||||
with get_session() as s:
|
||||
query = select(DailyQuote.date, DailyQuote.close, DailyQuote.volume).where(
|
||||
DailyQuote.code == symbol
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.where(DailyQuote.date >= dt.date.fromisoformat(start_date))
|
||||
if end_date:
|
||||
query = query.where(DailyQuote.date <= dt.date.fromisoformat(end_date))
|
||||
|
||||
query = query.order_by(DailyQuote.date)
|
||||
rows = s.execute(query).all()
|
||||
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "无数据"}
|
||||
|
||||
engine = BacktestEngine(initial_capital, commission)
|
||||
|
||||
# 逐日回测
|
||||
for row in rows:
|
||||
date, close, volume = row
|
||||
data = {"close": float(close), "volume": int(volume)}
|
||||
strategy.on_data(engine, date, data)
|
||||
|
||||
# 计算基准(买入持有)
|
||||
bench_curve = []
|
||||
first_close = rows[0][1]
|
||||
for row in rows:
|
||||
bench_curve.append(float(row[1]) / float(first_close) * initial_capital)
|
||||
|
||||
metrics = engine.get_metrics()
|
||||
|
||||
# 交易明细
|
||||
trades_detail = [{
|
||||
"entry_date": p.entry_date.isoformat(),
|
||||
"exit_date": p.exit_date.isoformat() if p.exit_date else "",
|
||||
"entry_price": round(p.entry_price, 2),
|
||||
"exit_price": round(p.exit_price, 2) if p.exit_price else 0,
|
||||
"shares": round(p.shares, 2),
|
||||
"hold_days": p.hold_days,
|
||||
"pnl": round(p.pnl, 2),
|
||||
"pnl_pct": round(p.pnl_pct, 2),
|
||||
"reason": p.reason
|
||||
} for p in engine.closed_positions]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": symbol,
|
||||
"strategy": strategy.name,
|
||||
"dates": [d.isoformat() for d in engine.dates],
|
||||
"equity": [round(e, 2) for e in engine.equity_curve],
|
||||
"bench": [round(b, 2) for b in bench_curve],
|
||||
"metrics": metrics,
|
||||
"trades": trades_detail,
|
||||
"initial_capital": initial_capital,
|
||||
}
|
||||
|
||||
|
||||
def optimize_parameters(symbol: str,
|
||||
param_grid: Dict[str, List],
|
||||
strategy_class: type,
|
||||
metric: str = "sharpe_ratio") -> List[Dict[str, Any]]:
|
||||
"""参数网格优化
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
param_grid: 参数网格,如 {"fast": [3,5,10], "slow": [10,20,30]}
|
||||
strategy_class: 策略类
|
||||
metric: 优化目标指标
|
||||
|
||||
Returns:
|
||||
优化结果列表,按指标降序排列
|
||||
"""
|
||||
import itertools
|
||||
|
||||
keys = list(param_grid.keys())
|
||||
values = list(param_grid.values())
|
||||
|
||||
results = []
|
||||
|
||||
# 遍历所有参数组合
|
||||
for combo in itertools.product(*values):
|
||||
params = dict(zip(keys, combo))
|
||||
|
||||
try:
|
||||
strategy = strategy_class(**params)
|
||||
result = run_advanced_backtest(symbol, strategy)
|
||||
|
||||
if result["ok"]:
|
||||
results.append({
|
||||
"params": params,
|
||||
"metrics": result["metrics"],
|
||||
metric: result["metrics"].get(metric, 0)
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"优化失败 {params}: {e}")
|
||||
continue
|
||||
|
||||
# 按目标指标排序
|
||||
results.sort(key=lambda x: x[metric], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def compare_strategies(symbol: str,
|
||||
strategies: List[Strategy],
|
||||
initial_capital: float = 100000.0) -> Dict[str, Any]:
|
||||
"""策略对比
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
strategies: 策略列表
|
||||
initial_capital: 初始资金
|
||||
|
||||
Returns:
|
||||
对比结果
|
||||
"""
|
||||
results = []
|
||||
|
||||
for strategy in strategies:
|
||||
result = run_advanced_backtest(symbol, strategy, initial_capital=initial_capital)
|
||||
if result["ok"]:
|
||||
results.append({
|
||||
"strategy": strategy.name,
|
||||
"equity": result["equity"],
|
||||
"metrics": result["metrics"]
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": symbol,
|
||||
"dates": result["dates"] if results else [],
|
||||
"strategies": results
|
||||
}
|
||||
531
backend/event_driven.py
Normal file
531
backend/event_driven.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""事件驱动策略 — 基于财经事件的量化交易。
|
||||
|
||||
功能:
|
||||
1. 财报发布前后统计规律
|
||||
2. 限售解禁影响回测
|
||||
3. 高管增减持跟踪
|
||||
4. 行业政策事件库
|
||||
5. 事件驱动选股
|
||||
"""
|
||||
import datetime as dt
|
||||
from typing import List, Dict, Any, Optional
|
||||
from collections import defaultdict
|
||||
import numpy as np
|
||||
from sqlalchemy import select, and_, or_, func, desc
|
||||
|
||||
from db import get_session
|
||||
from models import CorporateEvent, PolicyEvent, DailyQuote, StockMetric, Security
|
||||
|
||||
|
||||
def collect_earnings_events(limit: int = 100) -> Dict[str, Any]:
|
||||
"""采集财报事件(模拟数据)
|
||||
|
||||
实际生产需要接入:
|
||||
- 东方财富财报日历
|
||||
- 巨潮资讯网
|
||||
- 新浪财经API
|
||||
"""
|
||||
# 模拟财报事件
|
||||
with get_session() as s:
|
||||
# 获取股票池
|
||||
stocks = s.execute(
|
||||
select(Security.code, Security.name).limit(limit)
|
||||
).all()
|
||||
|
||||
saved = 0
|
||||
for code, name in stocks:
|
||||
# 模拟财报发布
|
||||
event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90))
|
||||
|
||||
# 检查是否已存在
|
||||
exists = s.execute(
|
||||
select(CorporateEvent).where(
|
||||
and_(
|
||||
CorporateEvent.code == code,
|
||||
CorporateEvent.event_type == 'earnings',
|
||||
CorporateEvent.event_date == event_date
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not exists:
|
||||
impact = np.random.choice(['positive', 'negative', 'neutral'], p=[0.4, 0.3, 0.3])
|
||||
|
||||
event = CorporateEvent(
|
||||
code=code,
|
||||
name=name,
|
||||
event_type='earnings',
|
||||
event_date=event_date,
|
||||
title=f"{name}发布财报",
|
||||
description=f"{'业绩超预期' if impact == 'positive' else ('业绩不及预期' if impact == 'negative' else '业绩符合预期')}",
|
||||
impact=impact
|
||||
)
|
||||
s.add(event)
|
||||
saved += 1
|
||||
|
||||
s.commit()
|
||||
|
||||
return {"ok": True, "saved": saved}
|
||||
|
||||
|
||||
def analyze_earnings_pattern(days_before: int = 5, days_after: int = 10) -> Dict[str, Any]:
|
||||
"""分析财报发布前后的股价规律
|
||||
|
||||
Args:
|
||||
days_before: 财报前N天
|
||||
days_after: 财报后N天
|
||||
|
||||
Returns:
|
||||
统计结果
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取财报事件
|
||||
events = s.execute(
|
||||
select(CorporateEvent)
|
||||
.where(CorporateEvent.event_type == 'earnings')
|
||||
).scalars().all()
|
||||
|
||||
if not events:
|
||||
return {"ok": False, "msg": "暂无财报数据"}
|
||||
|
||||
# 按影响分类
|
||||
results = {
|
||||
'positive': {'before': [], 'after': [], 'count': 0},
|
||||
'negative': {'before': [], 'after': [], 'count': 0},
|
||||
'neutral': {'before': [], 'after': [], 'count': 0}
|
||||
}
|
||||
|
||||
for event in events:
|
||||
# 获取前后股价
|
||||
before_date = event.event_date - dt.timedelta(days=days_before)
|
||||
after_date = event.event_date + dt.timedelta(days=days_after)
|
||||
|
||||
quotes = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(
|
||||
and_(
|
||||
DailyQuote.code == event.code,
|
||||
DailyQuote.date >= before_date,
|
||||
DailyQuote.date <= after_date
|
||||
)
|
||||
)
|
||||
.order_by(DailyQuote.date)
|
||||
).scalars().all()
|
||||
|
||||
if len(quotes) < 2:
|
||||
continue
|
||||
|
||||
# 找到事件日期的位置
|
||||
event_idx = None
|
||||
for i, q in enumerate(quotes):
|
||||
if q.date >= event.event_date:
|
||||
event_idx = i
|
||||
break
|
||||
|
||||
if event_idx is None or event_idx == 0:
|
||||
continue
|
||||
|
||||
# 计算前后收益
|
||||
base_price = float(quotes[event_idx - 1].close)
|
||||
|
||||
# 事件前收益
|
||||
if event_idx > 0:
|
||||
before_return = (float(quotes[event_idx - 1].close) / float(quotes[0].close) - 1) * 100
|
||||
results[event.impact]['before'].append(before_return)
|
||||
|
||||
# 事件后收益
|
||||
if event_idx < len(quotes) - 1:
|
||||
after_return = (float(quotes[-1].close) / base_price - 1) * 100
|
||||
results[event.impact]['after'].append(after_return)
|
||||
|
||||
results[event.impact]['count'] += 1
|
||||
|
||||
# 计算统计指标
|
||||
summary = {}
|
||||
for impact, data in results.items():
|
||||
if data['count'] > 0:
|
||||
summary[impact] = {
|
||||
'count': data['count'],
|
||||
'avg_before': round(np.mean(data['before']), 2) if data['before'] else 0,
|
||||
'avg_after': round(np.mean(data['after']), 2) if data['after'] else 0,
|
||||
'win_rate_after': round(sum(1 for x in data['after'] if x > 0) / len(data['after']) * 100, 1) if data['after'] else 0
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days_before": days_before,
|
||||
"days_after": days_after,
|
||||
"summary": summary
|
||||
}
|
||||
|
||||
|
||||
def track_insider_trading(code: str = None, days: int = 180) -> Dict[str, Any]:
|
||||
"""跟踪高管增减持
|
||||
|
||||
Args:
|
||||
code: 股票代码(None表示全部)
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
增减持记录
|
||||
"""
|
||||
since = dt.date.today() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
query = select(CorporateEvent).where(
|
||||
and_(
|
||||
CorporateEvent.event_type == 'insider',
|
||||
CorporateEvent.event_date >= since
|
||||
)
|
||||
)
|
||||
|
||||
if code:
|
||||
query = query.where(CorporateEvent.code == code)
|
||||
|
||||
query = query.order_by(desc(CorporateEvent.event_date))
|
||||
|
||||
events = s.execute(query).scalars().all()
|
||||
|
||||
if not events:
|
||||
return {"ok": False, "msg": "暂无增减持数据"}
|
||||
|
||||
# 统计
|
||||
by_type = {'increase': [], 'decrease': []}
|
||||
for e in events:
|
||||
action = 'increase' if e.impact == 'positive' else 'decrease'
|
||||
by_type[action].append({
|
||||
'code': e.code,
|
||||
'name': e.name,
|
||||
'date': e.event_date.isoformat(),
|
||||
'title': e.title,
|
||||
'amount': e.amount
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"total": len(events),
|
||||
"increases": by_type['increase'],
|
||||
"decreases": by_type['decrease']
|
||||
}
|
||||
|
||||
|
||||
def analyze_unlock_impact(days: int = 90) -> Dict[str, Any]:
|
||||
"""分析限售解禁影响
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
解禁影响统计
|
||||
"""
|
||||
since = dt.date.today() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
events = s.execute(
|
||||
select(CorporateEvent)
|
||||
.where(
|
||||
and_(
|
||||
CorporateEvent.event_type == 'unlock',
|
||||
CorporateEvent.event_date >= since
|
||||
)
|
||||
)
|
||||
.order_by(CorporateEvent.event_date)
|
||||
).scalars().all()
|
||||
|
||||
if not events:
|
||||
return {"ok": False, "msg": "暂无解禁数据"}
|
||||
|
||||
results = []
|
||||
for event in events:
|
||||
# 获取解禁前后股价
|
||||
before_date = event.event_date - dt.timedelta(days=10)
|
||||
after_date = event.event_date + dt.timedelta(days=10)
|
||||
|
||||
quotes = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(
|
||||
and_(
|
||||
DailyQuote.code == event.code,
|
||||
DailyQuote.date >= before_date,
|
||||
DailyQuote.date <= after_date
|
||||
)
|
||||
)
|
||||
.order_by(DailyQuote.date)
|
||||
).scalars().all()
|
||||
|
||||
if len(quotes) < 5:
|
||||
continue
|
||||
|
||||
# 找到解禁日
|
||||
unlock_idx = None
|
||||
for i, q in enumerate(quotes):
|
||||
if q.date >= event.event_date:
|
||||
unlock_idx = i
|
||||
break
|
||||
|
||||
if unlock_idx and unlock_idx > 0 and unlock_idx < len(quotes) - 1:
|
||||
before_price = float(quotes[unlock_idx - 1].close)
|
||||
after_price = float(quotes[-1].close)
|
||||
impact_pct = (after_price / before_price - 1) * 100
|
||||
|
||||
results.append({
|
||||
'code': event.code,
|
||||
'name': event.name,
|
||||
'date': event.event_date.isoformat(),
|
||||
'amount': event.amount,
|
||||
'impact_pct': round(impact_pct, 2),
|
||||
'title': event.title
|
||||
})
|
||||
|
||||
# 统计
|
||||
if results:
|
||||
avg_impact = np.mean([r['impact_pct'] for r in results])
|
||||
negative_count = sum(1 for r in results if r['impact_pct'] < 0)
|
||||
|
||||
summary = {
|
||||
'total': len(results),
|
||||
'avg_impact': round(avg_impact, 2),
|
||||
'negative_ratio': round(negative_count / len(results) * 100, 1)
|
||||
}
|
||||
else:
|
||||
summary = {}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"summary": summary,
|
||||
"events": results
|
||||
}
|
||||
|
||||
|
||||
def get_policy_events(sector: str = None, days: int = 180) -> Dict[str, Any]:
|
||||
"""获取行业政策事件
|
||||
|
||||
Args:
|
||||
sector: 板块名称(None表示全部)
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
政策事件列表
|
||||
"""
|
||||
since = dt.date.today() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
query = select(PolicyEvent).where(PolicyEvent.event_date >= since)
|
||||
|
||||
if sector:
|
||||
query = query.where(PolicyEvent.sector == sector)
|
||||
|
||||
query = query.order_by(desc(PolicyEvent.event_date))
|
||||
|
||||
events = s.execute(query).scalars().all()
|
||||
|
||||
if not events:
|
||||
return {"ok": False, "msg": "暂无政策数据"}
|
||||
|
||||
results = []
|
||||
for e in events:
|
||||
results.append({
|
||||
'sector': e.sector,
|
||||
'date': e.event_date.isoformat(),
|
||||
'title': e.title,
|
||||
'policy_type': e.policy_type,
|
||||
'impact': e.impact,
|
||||
'affected_stocks': e.affected_stocks.split(',') if e.affected_stocks else []
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"total": len(results),
|
||||
"events": results
|
||||
}
|
||||
|
||||
|
||||
def event_driven_selector(event_types: List[str], days: int = 30) -> Dict[str, Any]:
|
||||
"""事件驱动选股
|
||||
|
||||
Args:
|
||||
event_types: 事件类型列表,如 ['earnings_positive', 'insider_increase']
|
||||
days: 最近N天的事件
|
||||
|
||||
Returns:
|
||||
选股结果
|
||||
"""
|
||||
since = dt.date.today() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
# 构建查询条件
|
||||
conditions = []
|
||||
|
||||
for et in event_types:
|
||||
if et == 'earnings_positive':
|
||||
conditions.append(
|
||||
and_(
|
||||
CorporateEvent.event_type == 'earnings',
|
||||
CorporateEvent.impact == 'positive'
|
||||
)
|
||||
)
|
||||
elif et == 'insider_increase':
|
||||
conditions.append(
|
||||
and_(
|
||||
CorporateEvent.event_type == 'insider',
|
||||
CorporateEvent.impact == 'positive'
|
||||
)
|
||||
)
|
||||
elif et == 'dividend':
|
||||
conditions.append(CorporateEvent.event_type == 'dividend')
|
||||
|
||||
if not conditions:
|
||||
return {"ok": False, "msg": "无效的事件类型"}
|
||||
|
||||
# 查询事件
|
||||
query = select(CorporateEvent).where(
|
||||
and_(
|
||||
CorporateEvent.event_date >= since,
|
||||
or_(*conditions)
|
||||
)
|
||||
).order_by(desc(CorporateEvent.event_date))
|
||||
|
||||
events = s.execute(query).scalars().all()
|
||||
|
||||
if not events:
|
||||
return {"ok": False, "msg": "无符合条件的事件"}
|
||||
|
||||
# 按股票聚合
|
||||
stock_events = defaultdict(list)
|
||||
for e in events:
|
||||
stock_events[e.code].append({
|
||||
'type': e.event_type,
|
||||
'date': e.event_date.isoformat(),
|
||||
'impact': e.impact,
|
||||
'title': e.title
|
||||
})
|
||||
|
||||
# 获取股票最新数据
|
||||
codes = list(stock_events.keys())
|
||||
metrics = {}
|
||||
for m in s.execute(
|
||||
select(StockMetric).where(StockMetric.code.in_(codes))
|
||||
).scalars():
|
||||
metrics[m.code] = {
|
||||
'name': m.name,
|
||||
'close': m.close,
|
||||
'pct': m.pct,
|
||||
'ret20': m.ret20
|
||||
}
|
||||
|
||||
# 构建结果
|
||||
results = []
|
||||
for code, evt_list in stock_events.items():
|
||||
info = metrics.get(code, {'name': code, 'close': 0, 'pct': 0, 'ret20': 0})
|
||||
results.append({
|
||||
'code': code,
|
||||
'name': info['name'],
|
||||
'close': info['close'],
|
||||
'pct': info['pct'],
|
||||
'ret20': info['ret20'],
|
||||
'events': evt_list,
|
||||
'event_score': len(evt_list) # 事件数量作为评分
|
||||
})
|
||||
|
||||
# 按事件评分排序
|
||||
results.sort(key=lambda x: x['event_score'], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"event_types": event_types,
|
||||
"count": len(results),
|
||||
"stocks": results[:50]
|
||||
}
|
||||
|
||||
|
||||
def seed_sample_events():
|
||||
"""生成示例事件数据(用于演示)"""
|
||||
with get_session() as s:
|
||||
# 获取股票池
|
||||
stocks = s.execute(
|
||||
select(Security.code, Security.name).limit(100)
|
||||
).all()
|
||||
|
||||
saved = 0
|
||||
|
||||
for code, name in stocks:
|
||||
# 随机生成不同类型的事件
|
||||
event_types = [
|
||||
('earnings', 'positive', f'{name}业绩超预期', 0),
|
||||
('earnings', 'negative', f'{name}业绩不及预期', 0),
|
||||
('insider', 'positive', f'{name}高管增持', np.random.uniform(0.1, 5)),
|
||||
('insider', 'negative', f'{name}高管减持', np.random.uniform(0.1, 5)),
|
||||
('unlock', 'negative', f'{name}限售解禁', np.random.uniform(1, 50)),
|
||||
('dividend', 'positive', f'{name}分红派息', np.random.uniform(0.5, 3))
|
||||
]
|
||||
|
||||
# 随机选择1-2个事件
|
||||
selected = np.random.choice(len(event_types), size=min(2, len(event_types)), replace=False)
|
||||
|
||||
for idx in selected:
|
||||
event_type, impact, title, amount = event_types[idx]
|
||||
event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90))
|
||||
|
||||
# 检查是否已存在
|
||||
exists = s.execute(
|
||||
select(CorporateEvent).where(
|
||||
and_(
|
||||
CorporateEvent.code == code,
|
||||
CorporateEvent.event_type == event_type,
|
||||
CorporateEvent.event_date == event_date
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not exists:
|
||||
event = CorporateEvent(
|
||||
code=code,
|
||||
name=name,
|
||||
event_type=event_type,
|
||||
event_date=event_date,
|
||||
title=title,
|
||||
amount=amount,
|
||||
impact=impact
|
||||
)
|
||||
s.add(event)
|
||||
saved += 1
|
||||
|
||||
# 生成政策事件
|
||||
policies = [
|
||||
('新能源', '新能源汽车补贴政策延续', 'subsidy', 'positive'),
|
||||
('半导体', '芯片产业扶持政策出台', 'support', 'positive'),
|
||||
('医药', '药品集采政策调整', 'regulation', 'negative'),
|
||||
('光伏', '光伏补贴退坡', 'subsidy', 'negative'),
|
||||
('人工智能', 'AI产业发展规划发布', 'support', 'positive'),
|
||||
]
|
||||
|
||||
for sector, title, policy_type, impact in policies:
|
||||
event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90))
|
||||
|
||||
exists = s.execute(
|
||||
select(PolicyEvent).where(
|
||||
and_(
|
||||
PolicyEvent.sector == sector,
|
||||
PolicyEvent.title == title
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not exists:
|
||||
policy = PolicyEvent(
|
||||
sector=sector,
|
||||
event_date=event_date,
|
||||
title=title,
|
||||
policy_type=policy_type,
|
||||
impact=impact
|
||||
)
|
||||
s.add(policy)
|
||||
saved += 1
|
||||
|
||||
s.commit()
|
||||
|
||||
return {"ok": True, "saved": saved}
|
||||
495
backend/financial_analysis.py
Normal file
495
backend/financial_analysis.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""财报深度解读 — 关键指标趋势、AI摘要、同行对比、异常预警。
|
||||
|
||||
功能:
|
||||
1. 财报关键指标趋势
|
||||
2. AI财报摘要
|
||||
3. 同行对比
|
||||
4. 财报异常预警
|
||||
5. 财报发布日历
|
||||
"""
|
||||
import datetime as dt
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from collections import defaultdict
|
||||
import numpy as np
|
||||
from sqlalchemy import select, and_, func, desc
|
||||
|
||||
from db import get_session
|
||||
from models import FinancialReport, Security, StockMetric
|
||||
import llm
|
||||
|
||||
|
||||
def seed_sample_reports():
|
||||
"""生成示例财报数据(用于演示)"""
|
||||
with get_session() as s:
|
||||
stocks = s.execute(
|
||||
select(Security.code, Security.name).limit(50)
|
||||
).all()
|
||||
|
||||
saved = 0
|
||||
for code, name in stocks:
|
||||
# 生成最近4个季度的财报
|
||||
base_date = dt.date(2023, 12, 31)
|
||||
|
||||
for i in range(4):
|
||||
report_date = base_date - dt.timedelta(days=i * 90)
|
||||
publish_date = report_date + dt.timedelta(days=30)
|
||||
|
||||
# 检查是否已存在
|
||||
exists = s.execute(
|
||||
select(FinancialReport).where(
|
||||
and_(
|
||||
FinancialReport.code == code,
|
||||
FinancialReport.report_date == report_date
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if exists:
|
||||
continue
|
||||
|
||||
# 模拟财务数据
|
||||
base_revenue = np.random.uniform(10, 500)
|
||||
growth = np.random.uniform(-20, 50)
|
||||
|
||||
report = FinancialReport(
|
||||
code=code,
|
||||
name=name,
|
||||
report_date=report_date,
|
||||
publish_date=publish_date,
|
||||
report_type='Q' + str((report_date.month // 3) or 4),
|
||||
revenue=round(base_revenue * (1 + i * 0.1), 2),
|
||||
net_profit=round(base_revenue * np.random.uniform(0.05, 0.2), 2),
|
||||
roe=round(np.random.uniform(5, 25), 2),
|
||||
gross_margin=round(np.random.uniform(20, 60), 2),
|
||||
revenue_growth=round(growth, 2),
|
||||
profit_growth=round(growth + np.random.uniform(-10, 10), 2),
|
||||
inventory=round(base_revenue * np.random.uniform(0.1, 0.3), 2),
|
||||
receivable=round(base_revenue * np.random.uniform(0.15, 0.4), 2),
|
||||
debt_ratio=round(np.random.uniform(30, 70), 2)
|
||||
)
|
||||
s.add(report)
|
||||
saved += 1
|
||||
|
||||
s.commit()
|
||||
|
||||
return {"ok": True, "saved": saved}
|
||||
|
||||
|
||||
def get_report_trend(code: str, periods: int = 8) -> Dict[str, Any]:
|
||||
"""获取财报关键指标趋势
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
periods: 统计期数
|
||||
|
||||
Returns:
|
||||
趋势数据
|
||||
"""
|
||||
with get_session() as s:
|
||||
reports = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(FinancialReport.code == code)
|
||||
.order_by(desc(FinancialReport.report_date))
|
||||
.limit(periods)
|
||||
).scalars().all()
|
||||
|
||||
if not reports:
|
||||
return {"ok": False, "msg": "暂无财报数据"}
|
||||
|
||||
# 反转顺序(从旧到新)
|
||||
reports = list(reversed(reports))
|
||||
|
||||
# 提取趋势数据
|
||||
dates = [r.report_date.isoformat() for r in reports]
|
||||
|
||||
trend_data = {
|
||||
"revenue": [r.revenue for r in reports],
|
||||
"net_profit": [r.net_profit for r in reports],
|
||||
"roe": [r.roe for r in reports],
|
||||
"gross_margin": [r.gross_margin for r in reports],
|
||||
"revenue_growth": [r.revenue_growth for r in reports],
|
||||
"profit_growth": [r.profit_growth for r in reports],
|
||||
"debt_ratio": [r.debt_ratio for r in reports]
|
||||
}
|
||||
|
||||
# 计算趋势(上升/下降/平稳)
|
||||
def calc_trend(values):
|
||||
if len(values) < 2:
|
||||
return "平稳"
|
||||
recent = np.mean(values[-2:])
|
||||
previous = np.mean(values[:2]) if len(values) >= 4 else values[0]
|
||||
change = (recent - previous) / previous if previous != 0 else 0
|
||||
if change > 0.1:
|
||||
return "上升"
|
||||
elif change < -0.1:
|
||||
return "下降"
|
||||
else:
|
||||
return "平稳"
|
||||
|
||||
trends = {
|
||||
key: calc_trend(values)
|
||||
for key, values in trend_data.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"code": code,
|
||||
"name": reports[0].name,
|
||||
"dates": dates,
|
||||
"data": trend_data,
|
||||
"trends": trends,
|
||||
"latest": {
|
||||
"revenue": reports[-1].revenue,
|
||||
"net_profit": reports[-1].net_profit,
|
||||
"roe": reports[-1].roe,
|
||||
"gross_margin": reports[-1].gross_margin,
|
||||
"revenue_growth": reports[-1].revenue_growth,
|
||||
"profit_growth": reports[-1].profit_growth
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_ai_summary(code: str) -> Dict[str, Any]:
|
||||
"""生成AI财报摘要
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
|
||||
Returns:
|
||||
AI摘要
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取最新财报
|
||||
report = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(FinancialReport.code == code)
|
||||
.order_by(desc(FinancialReport.report_date))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not report:
|
||||
return {"ok": False, "msg": "暂无财报数据"}
|
||||
|
||||
# 如果已有摘要,直接返回
|
||||
if report.ai_summary:
|
||||
return {
|
||||
"ok": True,
|
||||
"summary": report.ai_summary,
|
||||
"report_date": report.report_date.isoformat()
|
||||
}
|
||||
|
||||
# 构建提示词
|
||||
prompt = f"""请用一句话总结以下财报数据(40字以内):
|
||||
|
||||
公司:{report.name}({report.code})
|
||||
报告期:{report.report_date}
|
||||
营业收入:{report.revenue}亿元,同比增长{report.revenue_growth:+.1f}%
|
||||
净利润:{report.net_profit}亿元,同比增长{report.profit_growth:+.1f}%
|
||||
ROE:{report.roe}%
|
||||
毛利率:{report.gross_margin}%
|
||||
|
||||
要求:
|
||||
1. 一句话说明业绩是增长还是下降
|
||||
2. 提及最亮眼或最担忧的指标
|
||||
3. 给出简短评价(优秀/良好/一般/较差)
|
||||
4. 不超过40字
|
||||
|
||||
示例:业绩稳步增长,ROE达20%创新高,盈利能力优秀。
|
||||
"""
|
||||
|
||||
# 调用AI
|
||||
if llm.enabled():
|
||||
try:
|
||||
summary = llm.ask(prompt, max_tokens=100)
|
||||
# 保存摘要
|
||||
report.ai_summary = summary
|
||||
s.commit()
|
||||
except Exception as e:
|
||||
summary = f"营收{report.revenue}亿元({report.revenue_growth:+.1f}%),净利润{report.net_profit}亿元({report.profit_growth:+.1f}%)"
|
||||
else:
|
||||
summary = f"营收{report.revenue}亿元({report.revenue_growth:+.1f}%),净利润{report.net_profit}亿元({report.profit_growth:+.1f}%)"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"summary": summary,
|
||||
"report_date": report.report_date.isoformat()
|
||||
}
|
||||
|
||||
|
||||
def compare_with_peers(code: str, sector: str = None) -> Dict[str, Any]:
|
||||
"""同行对比
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
sector: 行业(可选)
|
||||
|
||||
Returns:
|
||||
对比结果
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取目标股票最新财报
|
||||
target = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(FinancialReport.code == code)
|
||||
.order_by(desc(FinancialReport.report_date))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not target:
|
||||
return {"ok": False, "msg": "暂无财报数据"}
|
||||
|
||||
# 获取同行业股票(简化:随机选取)
|
||||
peers = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(
|
||||
and_(
|
||||
FinancialReport.code != code,
|
||||
FinancialReport.report_date == target.report_date
|
||||
)
|
||||
)
|
||||
.limit(20)
|
||||
).scalars().all()
|
||||
|
||||
if not peers:
|
||||
return {"ok": False, "msg": "暂无同行数据"}
|
||||
|
||||
# 计算行业均值
|
||||
industry_avg = {
|
||||
"roe": np.mean([p.roe for p in peers]),
|
||||
"gross_margin": np.mean([p.gross_margin for p in peers]),
|
||||
"revenue_growth": np.mean([p.revenue_growth for p in peers]),
|
||||
"profit_growth": np.mean([p.profit_growth for p in peers]),
|
||||
"debt_ratio": np.mean([p.debt_ratio for p in peers])
|
||||
}
|
||||
|
||||
# 计算差异
|
||||
comparison = {
|
||||
"roe": {
|
||||
"value": target.roe,
|
||||
"industry_avg": round(industry_avg["roe"], 2),
|
||||
"diff": round(target.roe - industry_avg["roe"], 2),
|
||||
"better": target.roe > industry_avg["roe"]
|
||||
},
|
||||
"gross_margin": {
|
||||
"value": target.gross_margin,
|
||||
"industry_avg": round(industry_avg["gross_margin"], 2),
|
||||
"diff": round(target.gross_margin - industry_avg["gross_margin"], 2),
|
||||
"better": target.gross_margin > industry_avg["gross_margin"]
|
||||
},
|
||||
"revenue_growth": {
|
||||
"value": target.revenue_growth,
|
||||
"industry_avg": round(industry_avg["revenue_growth"], 2),
|
||||
"diff": round(target.revenue_growth - industry_avg["revenue_growth"], 2),
|
||||
"better": target.revenue_growth > industry_avg["revenue_growth"]
|
||||
},
|
||||
"profit_growth": {
|
||||
"value": target.profit_growth,
|
||||
"industry_avg": round(industry_avg["profit_growth"], 2),
|
||||
"diff": round(target.profit_growth - industry_avg["profit_growth"], 2),
|
||||
"better": target.profit_growth > industry_avg["profit_growth"]
|
||||
},
|
||||
"debt_ratio": {
|
||||
"value": target.debt_ratio,
|
||||
"industry_avg": round(industry_avg["debt_ratio"], 2),
|
||||
"diff": round(target.debt_ratio - industry_avg["debt_ratio"], 2),
|
||||
"better": target.debt_ratio < industry_avg["debt_ratio"] # 负债率越低越好
|
||||
}
|
||||
}
|
||||
|
||||
# 综合评分
|
||||
better_count = sum(1 for v in comparison.values() if v["better"])
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"code": code,
|
||||
"name": target.name,
|
||||
"report_date": target.report_date.isoformat(),
|
||||
"comparison": comparison,
|
||||
"better_count": better_count,
|
||||
"total_metrics": len(comparison),
|
||||
"conclusion": "优于行业" if better_count >= 3 else ("持平行业" if better_count == 2 else "弱于行业")
|
||||
}
|
||||
|
||||
|
||||
def detect_abnormalities(code: str) -> Dict[str, Any]:
|
||||
"""财报异常预警
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
|
||||
Returns:
|
||||
异常预警
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取最近2期财报
|
||||
reports = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(FinancialReport.code == code)
|
||||
.order_by(desc(FinancialReport.report_date))
|
||||
.limit(2)
|
||||
).scalars().all()
|
||||
|
||||
if len(reports) < 2:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
current, previous = reports[0], reports[1]
|
||||
|
||||
warnings = []
|
||||
|
||||
# 1. 存货激增
|
||||
if current.inventory > 0 and previous.inventory > 0:
|
||||
inventory_growth = (current.inventory / previous.inventory - 1) * 100
|
||||
if inventory_growth > 50:
|
||||
warnings.append({
|
||||
"type": "存货激增",
|
||||
"severity": "high",
|
||||
"description": f"存货增长{inventory_growth:.1f}%,可能存在滞销风险",
|
||||
"current": current.inventory,
|
||||
"previous": previous.inventory
|
||||
})
|
||||
|
||||
# 2. 应收账款占比过高
|
||||
receivable_ratio = current.receivable / current.revenue * 100 if current.revenue > 0 else 0
|
||||
if receivable_ratio > 50:
|
||||
warnings.append({
|
||||
"type": "应收账款占比过高",
|
||||
"severity": "medium",
|
||||
"description": f"应收账款占营收{receivable_ratio:.1f}%,回款压力较大",
|
||||
"ratio": round(receivable_ratio, 2)
|
||||
})
|
||||
|
||||
# 3. 毛利率大幅下降
|
||||
if current.gross_margin > 0 and previous.gross_margin > 0:
|
||||
margin_change = current.gross_margin - previous.gross_margin
|
||||
if margin_change < -5:
|
||||
warnings.append({
|
||||
"type": "毛利率大幅下降",
|
||||
"severity": "high",
|
||||
"description": f"毛利率下降{abs(margin_change):.1f}个百分点,盈利能力恶化",
|
||||
"current": current.gross_margin,
|
||||
"previous": previous.gross_margin
|
||||
})
|
||||
|
||||
# 4. 资产负债率过高
|
||||
if current.debt_ratio > 70:
|
||||
warnings.append({
|
||||
"type": "资产负债率过高",
|
||||
"severity": "medium",
|
||||
"description": f"资产负债率{current.debt_ratio}%,财务风险较高",
|
||||
"value": current.debt_ratio
|
||||
})
|
||||
|
||||
# 5. 增收不增利
|
||||
if current.revenue_growth > 10 and current.profit_growth < 0:
|
||||
warnings.append({
|
||||
"type": "增收不增利",
|
||||
"severity": "high",
|
||||
"description": f"营收增长{current.revenue_growth:.1f}%,但净利润下降{abs(current.profit_growth):.1f}%",
|
||||
"revenue_growth": current.revenue_growth,
|
||||
"profit_growth": current.profit_growth
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"code": code,
|
||||
"name": current.name,
|
||||
"report_date": current.report_date.isoformat(),
|
||||
"warnings": warnings,
|
||||
"risk_level": "高" if any(w["severity"] == "high" for w in warnings) else ("中" if warnings else "低")
|
||||
}
|
||||
|
||||
|
||||
def get_report_calendar(days: int = 30) -> Dict[str, Any]:
|
||||
"""财报发布日历
|
||||
|
||||
Args:
|
||||
days: 未来N天
|
||||
|
||||
Returns:
|
||||
日历数据
|
||||
"""
|
||||
today = dt.date.today()
|
||||
end_date = today + dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
reports = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(
|
||||
and_(
|
||||
FinancialReport.publish_date >= today,
|
||||
FinancialReport.publish_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(FinancialReport.publish_date)
|
||||
).scalars().all()
|
||||
|
||||
if not reports:
|
||||
return {"ok": False, "msg": "暂无即将发布的财报"}
|
||||
|
||||
# 按日期分组
|
||||
calendar = defaultdict(list)
|
||||
for r in reports:
|
||||
calendar[r.publish_date.isoformat()].append({
|
||||
"code": r.code,
|
||||
"name": r.name,
|
||||
"report_date": r.report_date.isoformat(),
|
||||
"report_type": r.report_type
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"total": len(reports),
|
||||
"calendar": dict(calendar)
|
||||
}
|
||||
|
||||
|
||||
def get_top_reports(metric: str = "roe", limit: int = 20) -> Dict[str, Any]:
|
||||
"""获取财报排行榜
|
||||
|
||||
Args:
|
||||
metric: 排序指标(roe/gross_margin/revenue_growth)
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
排行榜
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取最新一期的所有财报
|
||||
latest_date = s.execute(
|
||||
select(func.max(FinancialReport.report_date))
|
||||
).scalar()
|
||||
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
# 根据指标排序
|
||||
order_field = getattr(FinancialReport, metric)
|
||||
|
||||
reports = s.execute(
|
||||
select(FinancialReport)
|
||||
.where(FinancialReport.report_date == latest_date)
|
||||
.order_by(desc(order_field))
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
results = []
|
||||
for r in reports:
|
||||
results.append({
|
||||
"code": r.code,
|
||||
"name": r.name,
|
||||
"roe": r.roe,
|
||||
"gross_margin": r.gross_margin,
|
||||
"revenue_growth": r.revenue_growth,
|
||||
"profit_growth": r.profit_growth,
|
||||
metric: getattr(r, metric)
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"metric": metric,
|
||||
"report_date": latest_date.isoformat(),
|
||||
"rankings": results
|
||||
}
|
||||
|
||||
416
backend/intraday_radar.py
Normal file
416
backend/intraday_radar.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""盘中实时监控雷达 — 异动检测与推送。
|
||||
|
||||
监控类型:
|
||||
1. 快速拉升(5分钟涨幅 >3%)
|
||||
2. 放量突破(量比 >3 且突破关键位)
|
||||
3. 涨停打开/炸板
|
||||
4. 连板股追踪
|
||||
5. 大单异动(单笔超百万)
|
||||
"""
|
||||
import datetime as dt
|
||||
from typing import List, Dict, Any
|
||||
from sqlalchemy import select, desc, func
|
||||
from cachetools import TTLCache
|
||||
|
||||
import akshare_service as svc
|
||||
import notifier
|
||||
from db import get_session
|
||||
from models import IntradayEvent, StockMetric, Security, DailyQuote
|
||||
|
||||
# 缓存最近检测到的事件,避免短时间内重复推送
|
||||
_event_cache = TTLCache(maxsize=1000, ttl=300) # 5分钟缓存
|
||||
|
||||
|
||||
def _is_trading_time() -> bool:
|
||||
"""判断是否为交易时间(9:30-11:30, 13:00-15:00)。"""
|
||||
now = dt.datetime.now()
|
||||
if now.weekday() >= 5: # 周末
|
||||
return False
|
||||
t = now.time()
|
||||
morning = dt.time(9, 30) <= t <= dt.time(11, 30)
|
||||
afternoon = dt.time(13, 0) <= t <= dt.time(15, 0)
|
||||
return morning or afternoon
|
||||
|
||||
|
||||
def _cache_key(code: str, event_type: str) -> str:
|
||||
"""生成事件缓存键。"""
|
||||
return f"{code}:{event_type}"
|
||||
|
||||
|
||||
def detect_surge(threshold: float = 3.0) -> List[Dict[str, Any]]:
|
||||
"""快速拉升检测(基于实时报价,模拟5分钟涨幅)。
|
||||
|
||||
Args:
|
||||
threshold: 涨幅阈值(%)
|
||||
|
||||
Returns:
|
||||
检测到的异动列表
|
||||
"""
|
||||
if not _is_trading_time():
|
||||
return []
|
||||
|
||||
events = []
|
||||
try:
|
||||
# 获取涨幅榜前50(模拟快速拉升)
|
||||
data = svc.get_hot_stocks()
|
||||
if not data.get("list"):
|
||||
return []
|
||||
|
||||
for stock in data["list"][:50]:
|
||||
pct = stock.get("pct", 0)
|
||||
if pct >= threshold:
|
||||
code = stock["code"]
|
||||
key = _cache_key(code, "surge")
|
||||
if key in _event_cache:
|
||||
continue
|
||||
|
||||
_event_cache[key] = True
|
||||
events.append({
|
||||
"code": code,
|
||||
"name": stock.get("name", code),
|
||||
"event_type": "surge",
|
||||
"price": stock.get("price", 0),
|
||||
"pct": pct,
|
||||
"description": f"快速拉升 {pct:.2f}%"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[surge] error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def detect_volume_break(vol_ratio_threshold: float = 3.0) -> List[Dict[str, Any]]:
|
||||
"""放量突破检测(量比 >3 且价格突破)。
|
||||
|
||||
Args:
|
||||
vol_ratio_threshold: 量比阈值
|
||||
|
||||
Returns:
|
||||
检测到的异动列表
|
||||
"""
|
||||
if not _is_trading_time():
|
||||
return []
|
||||
|
||||
events = []
|
||||
with get_session() as s:
|
||||
# 查询高量比且上涨的股票
|
||||
rows = s.execute(
|
||||
select(StockMetric)
|
||||
.where(StockMetric.vol_ratio >= vol_ratio_threshold, StockMetric.pct > 0)
|
||||
.order_by(StockMetric.vol_ratio.desc())
|
||||
.limit(20)
|
||||
).scalars().all()
|
||||
|
||||
for r in rows:
|
||||
key = _cache_key(r.code, "volume_break")
|
||||
if key in _event_cache:
|
||||
continue
|
||||
|
||||
# 判断是否突破关键位(60日新高或MA20)
|
||||
is_break = r.pos60 >= 0.95 or (r.close > r.ma20 and r.ma20 > 0)
|
||||
if is_break:
|
||||
_event_cache[key] = True
|
||||
events.append({
|
||||
"code": r.code,
|
||||
"name": r.name,
|
||||
"event_type": "volume_break",
|
||||
"price": r.close,
|
||||
"pct": r.pct,
|
||||
"volume_ratio": r.vol_ratio,
|
||||
"description": f"放量突破 量比{r.vol_ratio:.1f}"
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def detect_limit_open() -> List[Dict[str, Any]]:
|
||||
"""涨停打开/炸板检测。
|
||||
|
||||
Returns:
|
||||
检测到的异动列表
|
||||
"""
|
||||
if not _is_trading_time():
|
||||
return []
|
||||
|
||||
events = []
|
||||
try:
|
||||
# 获取涨停股
|
||||
data = svc.get_hot_stocks()
|
||||
if not data.get("list"):
|
||||
return []
|
||||
|
||||
with get_session() as s:
|
||||
for stock in data["list"]:
|
||||
pct = stock.get("pct", 0)
|
||||
# 涨停附近但未封死(9.5%-9.99%)
|
||||
if 9.5 <= pct < 9.99:
|
||||
code = stock["code"]
|
||||
key = _cache_key(code, "limit_open")
|
||||
if key in _event_cache:
|
||||
continue
|
||||
|
||||
_event_cache[key] = True
|
||||
events.append({
|
||||
"code": code,
|
||||
"name": stock.get("name", code),
|
||||
"event_type": "limit_open",
|
||||
"price": stock.get("price", 0),
|
||||
"pct": pct,
|
||||
"description": f"涨停打开 {pct:.2f}%"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[limit_open] error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def detect_consecutive_limit() -> List[Dict[str, Any]]:
|
||||
"""连板股追踪(2连板及以上)。
|
||||
|
||||
Returns:
|
||||
检测到的异动列表
|
||||
"""
|
||||
if not _is_trading_time():
|
||||
return []
|
||||
|
||||
events = []
|
||||
try:
|
||||
data = svc.get_hot_stocks()
|
||||
if not data.get("list"):
|
||||
return []
|
||||
|
||||
with get_session() as s:
|
||||
for stock in data["list"]:
|
||||
pct = stock.get("pct", 0)
|
||||
if pct >= 9.9: # 涨停
|
||||
code = stock["code"]
|
||||
|
||||
# 查询历史连板数
|
||||
recent = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(DailyQuote.code == code)
|
||||
.order_by(DailyQuote.date.desc())
|
||||
.limit(5)
|
||||
).scalars().all()
|
||||
|
||||
if not recent:
|
||||
continue
|
||||
|
||||
# 统计连续涨停天数
|
||||
consecutive = 1 # 今天涨停
|
||||
for q in recent[1:]:
|
||||
if q.close / q.open >= 1.095: # 近似判断涨停
|
||||
consecutive += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if consecutive >= 2:
|
||||
key = _cache_key(code, "consecutive")
|
||||
if key in _event_cache:
|
||||
continue
|
||||
|
||||
_event_cache[key] = True
|
||||
events.append({
|
||||
"code": code,
|
||||
"name": stock.get("name", code),
|
||||
"event_type": "consecutive",
|
||||
"price": stock.get("price", 0),
|
||||
"pct": pct,
|
||||
"description": f"{consecutive}连板"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[consecutive] error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def detect_big_order(threshold: float = 1000000.0) -> List[Dict[str, Any]]:
|
||||
"""大单异动检测(单笔超百万)。
|
||||
|
||||
注意:AkShare 免费接口无实时逐笔数据,此处返回空列表,可接入付费数据源。
|
||||
|
||||
Args:
|
||||
threshold: 单笔金额阈值(元)
|
||||
|
||||
Returns:
|
||||
检测到的异动列表
|
||||
"""
|
||||
# 需要付费数据源支持,暂不实现
|
||||
return []
|
||||
|
||||
|
||||
def scan_all() -> Dict[str, Any]:
|
||||
"""执行全部异动扫描。
|
||||
|
||||
Returns:
|
||||
扫描结果,包含各类异动事件
|
||||
"""
|
||||
if not _is_trading_time():
|
||||
return {"ok": False, "msg": "非交易时间", "events": []}
|
||||
|
||||
all_events = []
|
||||
|
||||
# 执行各类检测
|
||||
all_events.extend(detect_surge())
|
||||
all_events.extend(detect_volume_break())
|
||||
all_events.extend(detect_limit_open())
|
||||
all_events.extend(detect_consecutive_limit())
|
||||
all_events.extend(detect_big_order())
|
||||
|
||||
# 写入数据库
|
||||
if all_events:
|
||||
with get_session() as s:
|
||||
for evt in all_events:
|
||||
record = IntradayEvent(
|
||||
code=evt["code"],
|
||||
name=evt["name"],
|
||||
event_type=evt["event_type"],
|
||||
price=evt.get("price", 0),
|
||||
pct=evt.get("pct", 0),
|
||||
volume_ratio=evt.get("volume_ratio", 0),
|
||||
amount=evt.get("amount", 0),
|
||||
description=evt["description"]
|
||||
)
|
||||
s.add(record)
|
||||
s.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"count": len(all_events),
|
||||
"events": all_events,
|
||||
"scanned_at": dt.datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def get_recent_events(hours: int = 2, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""获取最近N小时的异动事件。
|
||||
|
||||
Args:
|
||||
hours: 时间范围(小时)
|
||||
limit: 最大返回数量
|
||||
|
||||
Returns:
|
||||
异动事件列表
|
||||
"""
|
||||
since = dt.datetime.now() - dt.timedelta(hours=hours)
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(IntradayEvent)
|
||||
.where(IntradayEvent.detected_at >= since)
|
||||
.order_by(desc(IntradayEvent.detected_at))
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
return [{
|
||||
"id": r.id,
|
||||
"code": r.code,
|
||||
"name": r.name,
|
||||
"event_type": r.event_type,
|
||||
"price": r.price,
|
||||
"pct": r.pct,
|
||||
"volume_ratio": r.volume_ratio,
|
||||
"amount": r.amount,
|
||||
"description": r.description,
|
||||
"detected_at": r.detected_at.strftime("%H:%M:%S"),
|
||||
"notified": r.notified
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def notify_events(event_types: List[str] = None) -> Dict[str, Any]:
|
||||
"""推送未通知的异动事件。
|
||||
|
||||
Args:
|
||||
event_types: 需要推送的事件类型列表,None表示全部
|
||||
|
||||
Returns:
|
||||
推送结果
|
||||
"""
|
||||
with get_session() as s:
|
||||
stmt = select(IntradayEvent).where(IntradayEvent.notified.is_(False))
|
||||
if event_types:
|
||||
stmt = stmt.where(IntradayEvent.event_type.in_(event_types))
|
||||
|
||||
rows = s.execute(stmt.order_by(desc(IntradayEvent.detected_at)).limit(10)).scalars().all()
|
||||
|
||||
if not rows:
|
||||
return {"ok": True, "count": 0, "msg": "无待推送事件"}
|
||||
|
||||
# 按事件类型分组
|
||||
grouped = {}
|
||||
for r in rows:
|
||||
if r.event_type not in grouped:
|
||||
grouped[r.event_type] = []
|
||||
grouped[r.event_type].append(r)
|
||||
|
||||
# 构造推送消息
|
||||
type_names = {
|
||||
"surge": "快速拉升",
|
||||
"volume_break": "放量突破",
|
||||
"limit_open": "涨停打开",
|
||||
"consecutive": "连板追踪",
|
||||
"big_order": "大单异动"
|
||||
}
|
||||
|
||||
msg_parts = ["【盘中异动雷达】\n"]
|
||||
for etype, events in grouped.items():
|
||||
msg_parts.append(f"\n{type_names.get(etype, etype)}:")
|
||||
for e in events[:5]: # 每类最多5条
|
||||
msg_parts.append(f"• {e.name}({e.code}) {e.description}")
|
||||
|
||||
msg = "\n".join(msg_parts)
|
||||
|
||||
# 推送
|
||||
if notifier.any_enabled():
|
||||
notifier.notify("盘中异动提醒", msg)
|
||||
|
||||
# 标记已推送
|
||||
for r in rows:
|
||||
r.notified = True
|
||||
s.commit()
|
||||
|
||||
return {"ok": True, "count": len(rows), "msg": f"已推送 {len(rows)} 条异动"}
|
||||
|
||||
|
||||
def get_statistics(date: dt.date = None) -> Dict[str, Any]:
|
||||
"""获取异动统计数据。
|
||||
|
||||
Args:
|
||||
date: 统计日期,None表示今天
|
||||
|
||||
Returns:
|
||||
统计结果
|
||||
"""
|
||||
if date is None:
|
||||
date = dt.date.today()
|
||||
|
||||
start = dt.datetime.combine(date, dt.time.min)
|
||||
end = dt.datetime.combine(date, dt.time.max)
|
||||
|
||||
with get_session() as s:
|
||||
# 按事件类型统计
|
||||
stmt = (
|
||||
select(IntradayEvent.event_type, func.count().label("count"))
|
||||
.where(IntradayEvent.detected_at >= start, IntradayEvent.detected_at <= end)
|
||||
.group_by(IntradayEvent.event_type)
|
||||
)
|
||||
rows = s.execute(stmt).all()
|
||||
|
||||
stats = {row.event_type: row.count for row in rows}
|
||||
total = sum(stats.values())
|
||||
|
||||
# 最活跃股票
|
||||
stmt = (
|
||||
select(IntradayEvent.code, IntradayEvent.name, func.count().label("count"))
|
||||
.where(IntradayEvent.detected_at >= start, IntradayEvent.detected_at <= end)
|
||||
.group_by(IntradayEvent.code, IntradayEvent.name)
|
||||
.order_by(desc("count"))
|
||||
.limit(10)
|
||||
)
|
||||
top_stocks = s.execute(stmt).all()
|
||||
|
||||
return {
|
||||
"date": date.isoformat(),
|
||||
"total": total,
|
||||
"by_type": stats,
|
||||
"top_stocks": [{"code": r.code, "name": r.name, "count": r.count} for r in top_stocks]
|
||||
}
|
||||
394
backend/limit_analysis.py
Normal file
394
backend/limit_analysis.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""涨跌停分析 — 连板股追踪、炸板率统计、敢死队排行。
|
||||
|
||||
功能:
|
||||
1. 连板股追踪器
|
||||
2. 炸板率统计
|
||||
3. 涨停敢死队排行
|
||||
"""
|
||||
import datetime as dt
|
||||
from typing import List, Dict, Any, Optional
|
||||
from collections import defaultdict, Counter
|
||||
import numpy as np
|
||||
from sqlalchemy import select, and_, func, desc
|
||||
|
||||
from db import get_session
|
||||
from models import DailyQuote, StockMetric
|
||||
|
||||
|
||||
def get_limit_stocks(date: Optional[dt.date] = None, limit_type: str = "up") -> Dict[str, Any]:
|
||||
"""获取涨停/跌停股票
|
||||
|
||||
Args:
|
||||
date: 日期(None表示最新)
|
||||
limit_type: up涨停/down跌停
|
||||
|
||||
Returns:
|
||||
涨跌停股票列表
|
||||
"""
|
||||
with get_session() as s:
|
||||
if date is None:
|
||||
date = s.execute(select(func.max(DailyQuote.date))).scalar()
|
||||
|
||||
if not date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
# 查询当日股票
|
||||
quotes = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(DailyQuote.date == date)
|
||||
).scalars().all()
|
||||
|
||||
if not quotes:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
# 筛选涨停/跌停股(涨跌幅接近±10%)
|
||||
threshold = 9.8 # 考虑精度问题,用9.8%作为阈值
|
||||
|
||||
results = []
|
||||
for q in quotes:
|
||||
if q.open == 0:
|
||||
continue
|
||||
|
||||
pct = (float(q.close) - float(q.open)) / float(q.open) * 100
|
||||
|
||||
if limit_type == "up" and pct >= threshold:
|
||||
results.append({
|
||||
"code": q.code,
|
||||
"name": q.name,
|
||||
"close": float(q.close),
|
||||
"pct": round(pct, 2),
|
||||
"volume": float(q.volume),
|
||||
"amount": float(q.amount)
|
||||
})
|
||||
elif limit_type == "down" and pct <= -threshold:
|
||||
results.append({
|
||||
"code": q.code,
|
||||
"name": q.name,
|
||||
"close": float(q.close),
|
||||
"pct": round(pct, 2),
|
||||
"volume": float(q.volume),
|
||||
"amount": float(q.amount)
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date": date.isoformat(),
|
||||
"type": limit_type,
|
||||
"count": len(results),
|
||||
"stocks": sorted(results, key=lambda x: x["pct"], reverse=(limit_type == "up"))
|
||||
}
|
||||
|
||||
|
||||
def track_consecutive_limits(days: int = 10) -> Dict[str, Any]:
|
||||
"""连板股追踪器
|
||||
|
||||
Args:
|
||||
days: 追踪天数
|
||||
|
||||
Returns:
|
||||
连板股列表
|
||||
"""
|
||||
with get_session() as s:
|
||||
latest_date = s.execute(select(func.max(DailyQuote.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
# 获取期间所有股票的日线数据
|
||||
quotes = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(DailyQuote.date >= start_date)
|
||||
.order_by(DailyQuote.code, DailyQuote.date)
|
||||
).scalars().all()
|
||||
|
||||
# 按股票分组
|
||||
stock_data = defaultdict(list)
|
||||
for q in quotes:
|
||||
stock_data[q.code].append(q)
|
||||
|
||||
# 统计连板
|
||||
consecutive_limits = []
|
||||
|
||||
for code, data in stock_data.items():
|
||||
if not data:
|
||||
continue
|
||||
|
||||
# 倒序遍历,统计从最新日期开始的连续涨停天数
|
||||
data_sorted = sorted(data, key=lambda x: x.date, reverse=True)
|
||||
|
||||
consecutive_days = 0
|
||||
for q in data_sorted:
|
||||
if q.open == 0:
|
||||
break
|
||||
|
||||
pct = (float(q.close) - float(q.open)) / float(q.open) * 100
|
||||
|
||||
if pct >= 9.8: # 涨停
|
||||
consecutive_days += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if consecutive_days >= 2: # 至少2连板
|
||||
latest = data_sorted[0]
|
||||
consecutive_limits.append({
|
||||
"code": code,
|
||||
"name": latest.name,
|
||||
"consecutive_days": consecutive_days,
|
||||
"close": float(latest.close),
|
||||
"amount": float(latest.amount),
|
||||
"status": f"{consecutive_days}连板"
|
||||
})
|
||||
|
||||
# 按连板天数排序
|
||||
consecutive_limits.sort(key=lambda x: x["consecutive_days"], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date": latest_date.isoformat(),
|
||||
"days": days,
|
||||
"count": len(consecutive_limits),
|
||||
"stocks": consecutive_limits
|
||||
}
|
||||
|
||||
|
||||
def analyze_limit_break_rate(days: int = 60) -> Dict[str, Any]:
|
||||
"""炸板率统计
|
||||
|
||||
分析涨停后次日的表现(继续涨停/上涨/下跌/跌停)
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
炸板率统计
|
||||
"""
|
||||
with get_session() as s:
|
||||
latest_date = s.execute(select(func.max(DailyQuote.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
# 获取期间所有股票的日线数据
|
||||
quotes = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(DailyQuote.date >= start_date)
|
||||
.order_by(DailyQuote.code, DailyQuote.date)
|
||||
).scalars().all()
|
||||
|
||||
# 按股票和日期组织数据
|
||||
stock_data = defaultdict(dict)
|
||||
for q in quotes:
|
||||
stock_data[q.code][q.date] = q
|
||||
|
||||
# 统计涨停后次日表现
|
||||
next_day_stats = {
|
||||
"limit_up": 0, # 继续涨停
|
||||
"up": 0, # 上涨但未涨停
|
||||
"down": 0, # 下跌但未跌停
|
||||
"limit_down": 0, # 跌停
|
||||
}
|
||||
|
||||
stock_break_rates = {} # 个股炸板率
|
||||
|
||||
for code, data in stock_data.items():
|
||||
dates = sorted(data.keys())
|
||||
stock_limits = 0
|
||||
stock_breaks = 0
|
||||
|
||||
for i in range(len(dates) - 1):
|
||||
today = dates[i]
|
||||
tomorrow = dates[i + 1]
|
||||
|
||||
today_q = data[today]
|
||||
tomorrow_q = data[tomorrow]
|
||||
|
||||
# 判断今日是否涨停
|
||||
if today_q.open == 0:
|
||||
continue
|
||||
|
||||
today_pct = (float(today_q.close) - float(today_q.open)) / float(today_q.open) * 100
|
||||
|
||||
if today_pct >= 9.8: # 今日涨停
|
||||
stock_limits += 1
|
||||
|
||||
# 判断次日表现
|
||||
if tomorrow_q.open == 0:
|
||||
continue
|
||||
|
||||
tomorrow_pct = (float(tomorrow_q.close) - float(tomorrow_q.open)) / float(tomorrow_q.open) * 100
|
||||
|
||||
if tomorrow_pct >= 9.8:
|
||||
next_day_stats["limit_up"] += 1
|
||||
elif tomorrow_pct > 0:
|
||||
next_day_stats["up"] += 1
|
||||
stock_breaks += 1 # 炸板
|
||||
elif tomorrow_pct > -9.8:
|
||||
next_day_stats["down"] += 1
|
||||
stock_breaks += 1 # 炸板
|
||||
else:
|
||||
next_day_stats["limit_down"] += 1
|
||||
stock_breaks += 1 # 炸板
|
||||
|
||||
# 计算个股炸板率
|
||||
if stock_limits > 0:
|
||||
break_rate = stock_breaks / stock_limits * 100
|
||||
if stock_limits >= 3: # 至少3次涨停才有统计意义
|
||||
stock_break_rates[code] = {
|
||||
"name": list(data.values())[0].name,
|
||||
"limits": stock_limits,
|
||||
"breaks": stock_breaks,
|
||||
"break_rate": round(break_rate, 2)
|
||||
}
|
||||
|
||||
total = sum(next_day_stats.values())
|
||||
|
||||
if total == 0:
|
||||
return {"ok": False, "msg": "统计样本不足"}
|
||||
|
||||
# 计算比例
|
||||
stats_with_pct = {
|
||||
"limit_up": {
|
||||
"count": next_day_stats["limit_up"],
|
||||
"pct": round(next_day_stats["limit_up"] / total * 100, 2)
|
||||
},
|
||||
"up": {
|
||||
"count": next_day_stats["up"],
|
||||
"pct": round(next_day_stats["up"] / total * 100, 2)
|
||||
},
|
||||
"down": {
|
||||
"count": next_day_stats["down"],
|
||||
"pct": round(next_day_stats["down"] / total * 100, 2)
|
||||
},
|
||||
"limit_down": {
|
||||
"count": next_day_stats["limit_down"],
|
||||
"pct": round(next_day_stats["limit_down"] / total * 100, 2)
|
||||
}
|
||||
}
|
||||
|
||||
# 炸板率 = (上涨未涨停 + 下跌 + 跌停) / 总数
|
||||
break_rate = (next_day_stats["up"] + next_day_stats["down"] + next_day_stats["limit_down"]) / total * 100
|
||||
|
||||
# 个股炸板率排行(从高到低)
|
||||
stock_rankings = sorted(
|
||||
[(code, data) for code, data in stock_break_rates.items()],
|
||||
key=lambda x: x[1]["break_rate"],
|
||||
reverse=True
|
||||
)[:30]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"total_samples": total,
|
||||
"overall_break_rate": round(break_rate, 2),
|
||||
"next_day_stats": stats_with_pct,
|
||||
"stock_rankings": [{
|
||||
"code": code,
|
||||
"name": data["name"],
|
||||
"limits": data["limits"],
|
||||
"breaks": data["breaks"],
|
||||
"break_rate": data["break_rate"]
|
||||
} for code, data in stock_rankings]
|
||||
}
|
||||
|
||||
|
||||
def get_limit_squad_rankings(days: int = 30, min_limits: int = 5) -> Dict[str, Any]:
|
||||
"""涨停敢死队排行
|
||||
|
||||
统计期间内涨停次数最多的股票(俗称"妖股")
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
min_limits: 最少涨停次数
|
||||
|
||||
Returns:
|
||||
敢死队排行
|
||||
"""
|
||||
with get_session() as s:
|
||||
latest_date = s.execute(select(func.max(DailyQuote.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
# 获取期间所有股票的日线数据
|
||||
quotes = s.execute(
|
||||
select(DailyQuote)
|
||||
.where(DailyQuote.date >= start_date)
|
||||
.order_by(DailyQuote.code, DailyQuote.date)
|
||||
).scalars().all()
|
||||
|
||||
# 统计每只股票的涨停次数
|
||||
limit_counts = defaultdict(lambda: {
|
||||
"name": "",
|
||||
"count": 0,
|
||||
"dates": [],
|
||||
"total_days": 0,
|
||||
"max_consecutive": 0
|
||||
})
|
||||
|
||||
# 按股票分组
|
||||
stock_data = defaultdict(list)
|
||||
for q in quotes:
|
||||
stock_data[q.code].append(q)
|
||||
|
||||
for code, data in stock_data.items():
|
||||
if not data:
|
||||
continue
|
||||
|
||||
data_sorted = sorted(data, key=lambda x: x.date)
|
||||
|
||||
limit_days = []
|
||||
for q in data_sorted:
|
||||
if q.open == 0:
|
||||
continue
|
||||
|
||||
pct = (float(q.close) - float(q.open)) / float(q.open) * 100
|
||||
|
||||
if pct >= 9.8: # 涨停
|
||||
limit_days.append(q.date)
|
||||
|
||||
if len(limit_days) >= min_limits:
|
||||
# 计算最大连板数
|
||||
max_consecutive = 1
|
||||
current_consecutive = 1
|
||||
|
||||
for i in range(1, len(limit_days)):
|
||||
if (limit_days[i] - limit_days[i-1]).days == 1:
|
||||
current_consecutive += 1
|
||||
max_consecutive = max(max_consecutive, current_consecutive)
|
||||
else:
|
||||
current_consecutive = 1
|
||||
|
||||
limit_counts[code] = {
|
||||
"name": data_sorted[0].name,
|
||||
"count": len(limit_days),
|
||||
"dates": [d.isoformat() for d in limit_days],
|
||||
"total_days": len(data_sorted),
|
||||
"max_consecutive": max_consecutive,
|
||||
"frequency": round(len(limit_days) / len(data_sorted) * 100, 2)
|
||||
}
|
||||
|
||||
# 排序
|
||||
rankings = sorted(
|
||||
[(code, data) for code, data in limit_counts.items()],
|
||||
key=lambda x: (x[1]["count"], x[1]["max_consecutive"]),
|
||||
reverse=True
|
||||
)[:50]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": latest_date.isoformat(),
|
||||
"count": len(rankings),
|
||||
"rankings": [{
|
||||
"code": code,
|
||||
"name": data["name"],
|
||||
"limit_count": data["count"],
|
||||
"max_consecutive": data["max_consecutive"],
|
||||
"frequency": data["frequency"],
|
||||
"dates": data["dates"]
|
||||
} for code, data in rankings]
|
||||
}
|
||||
@@ -40,3 +40,8 @@ def ask(user_content: str, temperature: float = 0.5, max_tokens: int = 900) -> s
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
], temperature=temperature, max_tokens=max_tokens)
|
||||
|
||||
|
||||
def ask_with_messages(messages: list, temperature: float = 0.5, max_tokens: int = 900) -> str:
|
||||
"""使用完整消息列表调用(支持多轮对话)"""
|
||||
return chat(messages, temperature=temperature, max_tokens=max_tokens)
|
||||
|
||||
537
backend/main.py
537
backend/main.py
@@ -1,4 +1,4 @@
|
||||
"""智策股票终端 — FastAPI 后端入口。
|
||||
"""Blackdata股票终端 — FastAPI 后端入口。
|
||||
|
||||
- /api/* : 数据接口(基于 AkShare,带缓存与降级)
|
||||
- / : 托管前端原型(prototype 目录)
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import json
|
||||
import datetime as dt
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -19,6 +20,7 @@ import akshare_service as svc
|
||||
import config
|
||||
import scheduler
|
||||
import backtest as bt
|
||||
import backtest_advanced as bta
|
||||
import ai
|
||||
import signals as sig
|
||||
import report as rpt
|
||||
@@ -26,10 +28,19 @@ import portfolio as pf
|
||||
import llm
|
||||
import alerts as al
|
||||
import notifier
|
||||
import intraday_radar as radar
|
||||
import sector_rotation as sector
|
||||
import smart_selector as selector
|
||||
import attribution_analysis as attrib
|
||||
import ai_chat
|
||||
import sentiment_monitor as sentiment
|
||||
import event_driven as events
|
||||
import financial_analysis as fin
|
||||
import limit_analysis as limit_up
|
||||
from db import init_db, get_session
|
||||
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
|
||||
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
|
||||
AlertRule, AlertEvent)
|
||||
AlertRule, AlertEvent, SelectorStrategy, SelectorAlert)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -43,7 +54,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Blackdata股票终端 API", version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -253,6 +264,98 @@ def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60
|
||||
return bt.run_backtest(symbol, fast, slow)
|
||||
|
||||
|
||||
# ============ 增强回测 ============
|
||||
class BacktestParams(BaseModel):
|
||||
symbol: str
|
||||
strategy: str = "ma" # ma, multi_factor
|
||||
fast: int = 5
|
||||
slow: int = 20
|
||||
position_size: float = 1.0
|
||||
stop_loss: float = 0.0
|
||||
take_profit: float = 0.0
|
||||
initial_capital: float = 100000.0
|
||||
commission: float = 0.0005
|
||||
|
||||
|
||||
@app.post("/api/backtest/advanced")
|
||||
def backtest_advanced(params: BacktestParams):
|
||||
"""增强回测"""
|
||||
if params.strategy == "ma":
|
||||
strategy = bta.MAStrategy(
|
||||
fast=params.fast,
|
||||
slow=params.slow,
|
||||
position_size=params.position_size,
|
||||
stop_loss=params.stop_loss,
|
||||
take_profit=params.take_profit
|
||||
)
|
||||
elif params.strategy == "multi_factor":
|
||||
strategy = bta.MultiFactorStrategy(position_size=params.position_size)
|
||||
else:
|
||||
return {"ok": False, "msg": "不支持的策略类型"}
|
||||
|
||||
return bta.run_advanced_backtest(
|
||||
symbol=params.symbol,
|
||||
strategy=strategy,
|
||||
initial_capital=params.initial_capital,
|
||||
commission=params.commission
|
||||
)
|
||||
|
||||
|
||||
class OptimizeParams(BaseModel):
|
||||
symbol: str
|
||||
strategy: str = "ma"
|
||||
fast_range: List[int] = [3, 5, 10, 15]
|
||||
slow_range: List[int] = [20, 30, 60]
|
||||
metric: str = "sharpe_ratio"
|
||||
|
||||
|
||||
@app.post("/api/backtest/optimize")
|
||||
def backtest_optimize(params: OptimizeParams):
|
||||
"""参数优化"""
|
||||
param_grid = {
|
||||
"fast": params.fast_range,
|
||||
"slow": params.slow_range
|
||||
}
|
||||
|
||||
results = bta.optimize_parameters(
|
||||
symbol=params.symbol,
|
||||
param_grid=param_grid,
|
||||
strategy_class=bta.MAStrategy,
|
||||
metric=params.metric
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": params.symbol,
|
||||
"metric": params.metric,
|
||||
"results": results[:20] # 返回前20个最优结果
|
||||
}
|
||||
|
||||
|
||||
class CompareParams(BaseModel):
|
||||
symbol: str
|
||||
strategies: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@app.post("/api/backtest/compare")
|
||||
def backtest_compare(params: CompareParams):
|
||||
"""策略对比"""
|
||||
strategies = []
|
||||
|
||||
for s in params.strategies:
|
||||
if s["type"] == "ma":
|
||||
strategies.append(bta.MAStrategy(
|
||||
fast=s.get("fast", 5),
|
||||
slow=s.get("slow", 20),
|
||||
stop_loss=s.get("stop_loss", 0),
|
||||
take_profit=s.get("take_profit", 0)
|
||||
))
|
||||
elif s["type"] == "multi_factor":
|
||||
strategies.append(bta.MultiFactorStrategy())
|
||||
|
||||
return bta.compare_strategies(params.symbol, strategies)
|
||||
|
||||
|
||||
# ============ 全市场选股 ============
|
||||
STRATEGIES = {
|
||||
"surge": "最近暴涨(5日涨幅≥20%)",
|
||||
@@ -479,6 +582,186 @@ def portfolio_equity():
|
||||
return pf.equity_curve()
|
||||
|
||||
|
||||
@app.get("/api/portfolio/attribution")
|
||||
def portfolio_attribution():
|
||||
"""持仓归因分析"""
|
||||
return attrib.analyze_attribution()
|
||||
|
||||
|
||||
# ============ AI 对话式分析 ============
|
||||
class ChatRequest(BaseModel):
|
||||
session_id: str
|
||||
message: str
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
def chat(req: ChatRequest):
|
||||
"""AI对话"""
|
||||
return ai_chat.chat(req.session_id, req.message)
|
||||
|
||||
|
||||
@app.delete("/api/chat/{session_id}")
|
||||
def clear_chat(session_id: str):
|
||||
"""清空会话"""
|
||||
ai_chat.clear_session(session_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/chat/{session_id}/history")
|
||||
def chat_history(session_id: str):
|
||||
"""获取会话历史"""
|
||||
return {"ok": True, "messages": ai_chat.get_session_history(session_id)}
|
||||
|
||||
|
||||
# ============ 社区情绪监控 ============
|
||||
@app.post("/api/sentiment/collect")
|
||||
def sentiment_collect(limit: int = Query(50, ge=10, le=200)):
|
||||
"""采集社区帖子"""
|
||||
return sentiment.collect_posts(limit)
|
||||
|
||||
|
||||
@app.get("/api/sentiment/index")
|
||||
def sentiment_index(date: Optional[str] = None):
|
||||
"""获取情绪指数"""
|
||||
d = dt.date.fromisoformat(date) if date else None
|
||||
return sentiment.calculate_sentiment_index(d)
|
||||
|
||||
|
||||
@app.get("/api/sentiment/hot_stocks")
|
||||
def sentiment_hot_stocks(days: int = Query(1, ge=1, le=7), limit: int = Query(20, le=50)):
|
||||
"""热议股票排行"""
|
||||
return sentiment.get_hot_stocks(days, limit)
|
||||
|
||||
|
||||
@app.get("/api/sentiment/history")
|
||||
def sentiment_history(days: int = Query(30, ge=7, le=90)):
|
||||
"""情绪指数历史"""
|
||||
return sentiment.get_sentiment_history(days)
|
||||
|
||||
|
||||
@app.get("/api/sentiment/correlation")
|
||||
def sentiment_correlation(code: str = Query(...), days: int = Query(60, ge=20, le=180)):
|
||||
"""情绪与股价相关性"""
|
||||
return sentiment.analyze_sentiment_correlation(code, days)
|
||||
|
||||
|
||||
@app.get("/api/sentiment/wordcloud")
|
||||
def sentiment_wordcloud(days: int = Query(7, ge=1, le=30), top_n: int = Query(50, le=100)):
|
||||
"""关键词云"""
|
||||
return sentiment.get_keyword_cloud(days, top_n)
|
||||
|
||||
|
||||
# ============ 事件驱动策略 ============
|
||||
@app.post("/api/events/seed")
|
||||
def events_seed():
|
||||
"""生成示例事件数据"""
|
||||
return events.seed_sample_events()
|
||||
|
||||
|
||||
@app.get("/api/events/earnings/pattern")
|
||||
def earnings_pattern(days_before: int = Query(5, ge=1, le=10), days_after: int = Query(10, ge=5, le=30)):
|
||||
"""财报发布前后统计规律"""
|
||||
return events.analyze_earnings_pattern(days_before, days_after)
|
||||
|
||||
|
||||
@app.get("/api/events/insider")
|
||||
def insider_trading(code: Optional[str] = None, days: int = Query(180, ge=30, le=365)):
|
||||
"""高管增减持跟踪"""
|
||||
return events.track_insider_trading(code, days)
|
||||
|
||||
|
||||
@app.get("/api/events/unlock")
|
||||
def unlock_impact(days: int = Query(90, ge=30, le=180)):
|
||||
"""限售解禁影响分析"""
|
||||
return events.analyze_unlock_impact(days)
|
||||
|
||||
|
||||
@app.get("/api/events/policy")
|
||||
def policy_events(sector: Optional[str] = None, days: int = Query(180, ge=30, le=365)):
|
||||
"""行业政策事件"""
|
||||
return events.get_policy_events(sector, days)
|
||||
|
||||
|
||||
class EventSelectorRequest(BaseModel):
|
||||
event_types: List[str]
|
||||
days: int = 30
|
||||
|
||||
|
||||
@app.post("/api/events/selector")
|
||||
def event_selector(req: EventSelectorRequest):
|
||||
"""事件驱动选股"""
|
||||
return events.event_driven_selector(req.event_types, req.days)
|
||||
|
||||
|
||||
# ============ 财报深度解读 ============
|
||||
@app.post("/api/financial/seed")
|
||||
def financial_seed():
|
||||
"""生成示例财报数据"""
|
||||
return fin.seed_sample_reports()
|
||||
|
||||
|
||||
@app.get("/api/financial/trend")
|
||||
def financial_trend(code: str = Query(...), periods: int = Query(8, ge=4, le=16)):
|
||||
"""财报关键指标趋势"""
|
||||
return fin.get_report_trend(code, periods)
|
||||
|
||||
|
||||
@app.get("/api/financial/summary")
|
||||
def financial_summary(code: str = Query(...)):
|
||||
"""AI财报摘要"""
|
||||
return fin.generate_ai_summary(code)
|
||||
|
||||
|
||||
@app.get("/api/financial/compare")
|
||||
def financial_compare(code: str = Query(...), sector: Optional[str] = None):
|
||||
"""同行对比"""
|
||||
return fin.compare_with_peers(code, sector)
|
||||
|
||||
|
||||
@app.get("/api/financial/warnings")
|
||||
def financial_warnings(code: str = Query(...)):
|
||||
"""财报异常预警"""
|
||||
return fin.detect_abnormalities(code)
|
||||
|
||||
|
||||
@app.get("/api/financial/calendar")
|
||||
def financial_calendar(days: int = Query(30, ge=7, le=90)):
|
||||
"""财报发布日历"""
|
||||
return fin.get_report_calendar(days)
|
||||
|
||||
|
||||
@app.get("/api/financial/rankings")
|
||||
def financial_rankings(metric: str = Query("roe"), limit: int = Query(20, le=50)):
|
||||
"""财报排行榜"""
|
||||
return fin.get_top_reports(metric, limit)
|
||||
|
||||
|
||||
# ============ 涨跌停分析 ============
|
||||
@app.get("/api/limit/stocks")
|
||||
def limit_stocks(date: Optional[str] = None, limit_type: str = Query("up")):
|
||||
"""获取涨停/跌停股票"""
|
||||
d = dt.date.fromisoformat(date) if date else None
|
||||
return limit_up.get_limit_stocks(d, limit_type)
|
||||
|
||||
|
||||
@app.get("/api/limit/consecutive")
|
||||
def consecutive_limits(days: int = Query(10, ge=5, le=30)):
|
||||
"""连板股追踪"""
|
||||
return limit_up.track_consecutive_limits(days)
|
||||
|
||||
|
||||
@app.get("/api/limit/break_rate")
|
||||
def limit_break_rate(days: int = Query(60, ge=30, le=180)):
|
||||
"""炸板率统计"""
|
||||
return limit_up.analyze_limit_break_rate(days)
|
||||
|
||||
|
||||
@app.get("/api/limit/squad")
|
||||
def limit_squad(days: int = Query(30, ge=10, le=90), min_limits: int = Query(5, ge=3, le=10)):
|
||||
"""涨停敢死队排行"""
|
||||
return limit_up.get_limit_squad_rankings(days, min_limits)
|
||||
|
||||
|
||||
# ============ 推送通知 ============
|
||||
@app.get("/api/notify/status")
|
||||
def notify_status():
|
||||
@@ -489,7 +772,7 @@ def notify_status():
|
||||
def notify_test():
|
||||
if not notifier.any_enabled():
|
||||
return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"}
|
||||
res = notifier.notify("【智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。")
|
||||
res = notifier.notify("【Blackdata】推送测试", "这是一条来自Blackdata股票终端的测试通知,收到即表示推送通道正常。")
|
||||
return {"ok": True, "result": res}
|
||||
|
||||
|
||||
@@ -613,6 +896,252 @@ def news_ai(n: NewsAI):
|
||||
"text": f"判断:{senti}(关键词:{'、'.join(kw) or '无'})。摘要:{text_in[:80]}…\n(配置大模型后可获得更深入的关联分析)"}
|
||||
|
||||
|
||||
# ============ 盘中实时监控雷达 ============
|
||||
@app.get("/api/radar/status")
|
||||
def radar_status():
|
||||
"""雷达状态。"""
|
||||
return {"trading_time": radar._is_trading_time()}
|
||||
|
||||
|
||||
@app.post("/api/radar/scan")
|
||||
def radar_scan():
|
||||
"""手动触发异动扫描。"""
|
||||
return radar.scan_all()
|
||||
|
||||
|
||||
@app.get("/api/radar/events")
|
||||
def radar_events(hours: int = Query(2, ge=1, le=24), limit: int = Query(50, le=200)):
|
||||
"""获取最近的异动事件。"""
|
||||
return {"list": radar.get_recent_events(hours, limit)}
|
||||
|
||||
|
||||
@app.post("/api/radar/notify")
|
||||
def radar_notify():
|
||||
"""推送未通知的异动。"""
|
||||
return radar.notify_events()
|
||||
|
||||
|
||||
@app.get("/api/radar/stats")
|
||||
def radar_stats(date: str = Query(None)):
|
||||
"""异动统计。"""
|
||||
d = dt.date.fromisoformat(date) if date else None
|
||||
return radar.get_statistics(d)
|
||||
|
||||
|
||||
# ============ 板块轮动分析 ============
|
||||
@app.get("/api/sector/trend")
|
||||
def sector_trend(days: int = Query(20, ge=5, le=60), top_n: int = Query(15, le=30)):
|
||||
"""板块强弱趋势"""
|
||||
return sector.get_sector_trend(days, top_n)
|
||||
|
||||
|
||||
@app.get("/api/sector/flow")
|
||||
def sector_flow(days: int = Query(5, ge=1, le=20)):
|
||||
"""资金流向分析"""
|
||||
return sector.analyze_fund_flow(days)
|
||||
|
||||
|
||||
@app.get("/api/sector/lifecycle")
|
||||
def sector_lifecycle(name: str = Query(...), days: int = Query(60, ge=20, le=120)):
|
||||
"""板块生命周期"""
|
||||
return sector.analyze_lifecycle(name, days)
|
||||
|
||||
|
||||
@app.get("/api/sector/leaders")
|
||||
def sector_leaders(name: str = Query(...), days: int = Query(20, ge=5, le=60), limit: int = Query(10, le=30)):
|
||||
"""龙头股识别"""
|
||||
return sector.identify_leaders(name, days, limit)
|
||||
|
||||
|
||||
@app.get("/api/sector/correlation")
|
||||
def sector_correlation(days: int = Query(60, ge=20, le=120), top_n: int = Query(20, le=30)):
|
||||
"""板块联动性分析"""
|
||||
return sector.analyze_correlation(days, top_n)
|
||||
|
||||
|
||||
@app.get("/api/sector/summary")
|
||||
def sector_summary():
|
||||
"""板块轮动摘要"""
|
||||
return sector.get_rotation_summary()
|
||||
|
||||
|
||||
# ============ 智能选股增强 ============
|
||||
@app.get("/api/selector/fields")
|
||||
def selector_fields():
|
||||
"""获取可用字段"""
|
||||
return {"ok": True, "fields": selector.get_available_fields()}
|
||||
|
||||
|
||||
@app.get("/api/selector/presets")
|
||||
def selector_presets():
|
||||
"""获取预设策略"""
|
||||
return {"ok": True, "presets": selector.get_preset_strategies()}
|
||||
|
||||
|
||||
class SelectorRequest(BaseModel):
|
||||
strategy: Dict[str, Any]
|
||||
date: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/selector/run")
|
||||
def selector_run(req: SelectorRequest):
|
||||
"""执行选股"""
|
||||
try:
|
||||
strategy = selector.Strategy.from_dict(req.strategy)
|
||||
date = dt.date.fromisoformat(req.date) if req.date else None
|
||||
return selector.run_selector(strategy, date)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/selector/backtest")
|
||||
def selector_backtest(req: SelectorRequest, days: int = Query(60, ge=20, le=250)):
|
||||
"""选股策略回测"""
|
||||
try:
|
||||
strategy = selector.Strategy.from_dict(req.strategy)
|
||||
return selector.backtest_selector(strategy, days)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
class CompareRequest(BaseModel):
|
||||
strategy: Dict[str, Any]
|
||||
date1: str
|
||||
date2: str
|
||||
|
||||
|
||||
@app.post("/api/selector/compare")
|
||||
def selector_compare(req: CompareRequest):
|
||||
"""对比选股结果"""
|
||||
try:
|
||||
strategy = selector.Strategy.from_dict(req.strategy)
|
||||
date1 = dt.date.fromisoformat(req.date1)
|
||||
date2 = dt.date.fromisoformat(req.date2)
|
||||
return selector.compare_results(date1, date2, strategy)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/selector/strategies")
|
||||
def list_strategies():
|
||||
"""获取保存的策略列表"""
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(SelectorStrategy).order_by(SelectorStrategy.updated_at.desc())
|
||||
).scalars().all()
|
||||
return {
|
||||
"ok": True,
|
||||
"strategies": [{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"is_preset": r.is_preset,
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"updated_at": r.updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
} for r in rows]
|
||||
}
|
||||
|
||||
|
||||
class SaveStrategyRequest(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
strategy: Dict[str, Any]
|
||||
|
||||
|
||||
@app.post("/api/selector/strategies")
|
||||
def save_strategy(req: SaveStrategyRequest):
|
||||
"""保存策略"""
|
||||
try:
|
||||
strategy = selector.Strategy.from_dict(req.strategy)
|
||||
with get_session() as s:
|
||||
record = SelectorStrategy(
|
||||
name=req.name,
|
||||
description=req.description,
|
||||
strategy_json=strategy.to_json()
|
||||
)
|
||||
s.add(record)
|
||||
s.commit()
|
||||
return {"ok": True, "id": record.id}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/selector/strategies/{sid}")
|
||||
def get_strategy(sid: int):
|
||||
"""获取策略详情"""
|
||||
with get_session() as s:
|
||||
record = s.get(SelectorStrategy, sid)
|
||||
if not record:
|
||||
return {"ok": False, "msg": "策略不存在"}
|
||||
return {
|
||||
"ok": True,
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"description": record.description,
|
||||
"strategy": json.loads(record.strategy_json)
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/selector/strategies/{sid}")
|
||||
def delete_strategy(sid: int):
|
||||
"""删除策略"""
|
||||
with get_session() as s:
|
||||
record = s.get(SelectorStrategy, sid)
|
||||
if record:
|
||||
s.delete(record)
|
||||
s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/selector/alerts")
|
||||
def list_selector_alerts():
|
||||
"""获取选股预警列表"""
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(SelectorAlert).order_by(SelectorAlert.id.desc())
|
||||
).scalars().all()
|
||||
return {
|
||||
"ok": True,
|
||||
"alerts": [{
|
||||
"id": r.id,
|
||||
"strategy_id": r.strategy_id,
|
||||
"strategy_name": r.strategy_name,
|
||||
"status": r.status,
|
||||
"last_checked": r.last_checked.strftime("%m-%d %H:%M") if r.last_checked else "",
|
||||
"last_count": r.last_count
|
||||
} for r in rows]
|
||||
}
|
||||
|
||||
|
||||
class CreateAlertRequest(BaseModel):
|
||||
strategy_id: int
|
||||
strategy_name: str
|
||||
|
||||
|
||||
@app.post("/api/selector/alerts")
|
||||
def create_selector_alert(req: CreateAlertRequest):
|
||||
"""创建选股预警"""
|
||||
with get_session() as s:
|
||||
record = SelectorAlert(
|
||||
strategy_id=req.strategy_id,
|
||||
strategy_name=req.strategy_name
|
||||
)
|
||||
s.add(record)
|
||||
s.commit()
|
||||
return {"ok": True, "id": record.id}
|
||||
|
||||
|
||||
@app.delete("/api/selector/alerts/{aid}")
|
||||
def delete_selector_alert(aid: int):
|
||||
"""删除选股预警"""
|
||||
with get_session() as s:
|
||||
record = s.get(SelectorAlert, aid)
|
||||
if record:
|
||||
s.delete(record)
|
||||
s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ 静态前端 ============
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype")
|
||||
if os.path.isdir(FRONTEND_DIR):
|
||||
|
||||
@@ -221,3 +221,131 @@ class JobRun(Base):
|
||||
started_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
finished_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
message: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class SelectorStrategy(Base):
|
||||
"""选股策略保存。"""
|
||||
__tablename__ = "selector_strategies"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
description: Mapped[str] = mapped_column(String(200), default="")
|
||||
strategy_json: Mapped[str] = mapped_column(Text) # JSON格式的策略定义
|
||||
is_preset: Mapped[bool] = mapped_column(default=False) # 是否预设策略
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class SelectorAlert(Base):
|
||||
"""选股条件预警。"""
|
||||
__tablename__ = "selector_alerts"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
strategy_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
strategy_name: Mapped[str] = mapped_column(String(80))
|
||||
status: Mapped[str] = mapped_column(String(12), default="active") # active/paused
|
||||
last_checked: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class SocialPost(Base):
|
||||
"""社区帖子。"""
|
||||
__tablename__ = "social_posts"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
source: Mapped[str] = mapped_column(String(20), index=True) # eastmoney/xueqiu/guba
|
||||
post_id: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
code: Mapped[str] = mapped_column(String(12), index=True, default="")
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
author: Mapped[str] = mapped_column(String(80), default="")
|
||||
comment_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sentiment: Mapped[str] = mapped_column(String(20), default="neutral") # bullish/bearish/neutral
|
||||
keywords: Mapped[str] = mapped_column(String(200), default="") # 逗号分隔
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class SentimentIndex(Base):
|
||||
"""社区情绪指数(每日)。"""
|
||||
__tablename__ = "sentiment_index"
|
||||
date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
|
||||
bullish_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
bearish_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
neutral_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
bullish_ratio: Mapped[float] = mapped_column(Float, default=0.0) # 0-100
|
||||
total_posts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
top_keywords: Mapped[str] = mapped_column(String(500), default="") # JSON格式
|
||||
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class CorporateEvent(Base):
|
||||
"""公司事件(财报、增减持、限售解禁等)。"""
|
||||
__tablename__ = "corporate_events"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
name: Mapped[str] = mapped_column(String(40), default="")
|
||||
event_type: Mapped[str] = mapped_column(String(20), index=True) # earnings/insider/unlock/dividend
|
||||
event_date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0) # 金额(亿元)
|
||||
impact: Mapped[str] = mapped_column(String(20), default="neutral") # positive/negative/neutral
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class PolicyEvent(Base):
|
||||
"""行业政策事件。"""
|
||||
__tablename__ = "policy_events"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sector: Mapped[str] = mapped_column(String(40), index=True) # 受影响板块
|
||||
event_date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
policy_type: Mapped[str] = mapped_column(String(40)) # subsidy/restriction/support/regulation
|
||||
impact: Mapped[str] = mapped_column(String(20), default="neutral")
|
||||
affected_stocks: Mapped[str] = mapped_column(String(500), default="") # 逗号分隔的股票代码
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class FinancialReport(Base):
|
||||
"""财务报表数据。"""
|
||||
__tablename__ = "financial_reports"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
name: Mapped[str] = mapped_column(String(40), default="")
|
||||
report_date: Mapped[dt.date] = mapped_column(Date, index=True) # 报告期
|
||||
publish_date: Mapped[dt.date] = mapped_column(Date, index=True) # 发布日期
|
||||
report_type: Mapped[str] = mapped_column(String(20)) # Q1/Q2/Q3/annual
|
||||
|
||||
# 核心指标
|
||||
revenue: Mapped[float] = mapped_column(Float, default=0.0) # 营收(亿元)
|
||||
net_profit: Mapped[float] = mapped_column(Float, default=0.0) # 净利润(亿元)
|
||||
roe: Mapped[float] = mapped_column(Float, default=0.0) # 净资产收益率(%)
|
||||
gross_margin: Mapped[float] = mapped_column(Float, default=0.0) # 毛利率(%)
|
||||
revenue_growth: Mapped[float] = mapped_column(Float, default=0.0) # 营收同比增长(%)
|
||||
profit_growth: Mapped[float] = mapped_column(Float, default=0.0) # 净利润同比增长(%)
|
||||
|
||||
# 风险指标
|
||||
inventory: Mapped[float] = mapped_column(Float, default=0.0) # 存货(亿元)
|
||||
receivable: Mapped[float] = mapped_column(Float, default=0.0) # 应收账款(亿元)
|
||||
debt_ratio: Mapped[float] = mapped_column(Float, default=0.0) # 资产负债率(%)
|
||||
|
||||
# AI摘要
|
||||
ai_summary: Mapped[str] = mapped_column(String(500), default="")
|
||||
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class IntradayEvent(Base):
|
||||
"""盘中异动事件记录。"""
|
||||
__tablename__ = "intraday_events"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
name: Mapped[str] = mapped_column(String(40), default="")
|
||||
event_type: Mapped[str] = mapped_column(String(20), index=True) # surge/volume_break/limit_open/consecutive/big_order
|
||||
price: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
pct: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
volume_ratio: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0) # 对于big_order是单笔金额
|
||||
description: Mapped[str] = mapped_column(String(200), default="")
|
||||
detected_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
notified: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
@@ -116,7 +116,7 @@ def generate(date=None, push=False):
|
||||
try:
|
||||
# 推送精简版(情绪 + 领涨 + AI 点评首段)
|
||||
brief = _push_brief(g, rv)
|
||||
res = notifier.notify("【智策】" + title, brief)
|
||||
res = notifier.notify("【Blackdata】" + title, brief)
|
||||
with get_session() as s:
|
||||
r2 = s.get(DailyReport, d)
|
||||
if r2:
|
||||
|
||||
@@ -6,3 +6,5 @@ cachetools==5.5.0
|
||||
SQLAlchemy>=2.0.30
|
||||
APScheduler>=3.10.4
|
||||
psycopg2-binary>=2.9.9
|
||||
jieba>=0.42.1
|
||||
numpy>=1.26.0
|
||||
|
||||
@@ -12,6 +12,7 @@ import ingest
|
||||
import alerts
|
||||
import report
|
||||
import signals
|
||||
import intraday_radar
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_lock = threading.Lock()
|
||||
@@ -133,6 +134,11 @@ def start_scheduler():
|
||||
_job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0),
|
||||
id="signal_stats", replace_existing=True, misfire_grace_time=7200,
|
||||
)
|
||||
# 盘中异动扫描(交易时间每分钟)
|
||||
_scheduler.add_job(
|
||||
_safe_scan_intraday, IntervalTrigger(seconds=60),
|
||||
id="intraday_scan", replace_existing=True, max_instances=1,
|
||||
)
|
||||
_scheduler.start()
|
||||
return _scheduler
|
||||
|
||||
@@ -142,3 +148,13 @@ def _safe_check_alerts():
|
||||
alerts.check_alerts()
|
||||
except Exception as e:
|
||||
print("[alert] check error:", repr(e)[:120])
|
||||
|
||||
|
||||
def _safe_scan_intraday():
|
||||
try:
|
||||
result = intraday_radar.scan_all()
|
||||
if result.get("count", 0) > 0:
|
||||
# 有新异动时自动推送
|
||||
intraday_radar.notify_events()
|
||||
except Exception as e:
|
||||
print("[intraday] scan error:", repr(e)[:120])
|
||||
|
||||
483
backend/sector_rotation.py
Normal file
483
backend/sector_rotation.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""板块轮动分析 — 追踪板块强弱、资金流向、生命周期。
|
||||
|
||||
功能:
|
||||
1. 板块强弱排名趋势
|
||||
2. 资金流向分析
|
||||
3. 板块生命周期判断
|
||||
4. 龙头股识别
|
||||
5. 板块联动性分析
|
||||
"""
|
||||
import datetime as dt
|
||||
from typing import Dict, List, Any, Optional
|
||||
import numpy as np
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
from db import get_session
|
||||
from models import SectorDaily, FundFlowDaily, DailyQuote, StockMetric
|
||||
|
||||
|
||||
def get_sector_trend(days: int = 20, top_n: int = 15) -> Dict[str, Any]:
|
||||
"""获取板块强弱趋势
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
top_n: 返回前N个板块
|
||||
|
||||
Returns:
|
||||
板块趋势数据
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取最近N天的日期
|
||||
latest_date = s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无板块数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
# 查询板块数据
|
||||
rows = s.execute(
|
||||
select(SectorDaily)
|
||||
.where(SectorDaily.date >= start_date)
|
||||
.order_by(SectorDaily.date, SectorDaily.name)
|
||||
).scalars().all()
|
||||
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
# 按板块聚合
|
||||
sector_data = {}
|
||||
for row in rows:
|
||||
if row.name not in sector_data:
|
||||
sector_data[row.name] = {
|
||||
"name": row.name,
|
||||
"dates": [],
|
||||
"pcts": [],
|
||||
"amounts": []
|
||||
}
|
||||
sector_data[row.name]["dates"].append(row.date.isoformat())
|
||||
sector_data[row.name]["pcts"].append(float(row.pct))
|
||||
sector_data[row.name]["amounts"].append(float(row.amount))
|
||||
|
||||
# 计算累计涨跌幅和平均成交额
|
||||
sector_stats = []
|
||||
for name, data in sector_data.items():
|
||||
pcts = data["pcts"]
|
||||
amounts = data["amounts"]
|
||||
|
||||
# 累计收益(复利)
|
||||
cumulative = 1.0
|
||||
for p in pcts:
|
||||
cumulative *= (1 + p / 100)
|
||||
cumulative_return = (cumulative - 1) * 100
|
||||
|
||||
# 近5日、10日、20日收益
|
||||
returns = {
|
||||
"5d": sum(pcts[-5:]) if len(pcts) >= 5 else 0,
|
||||
"10d": sum(pcts[-10:]) if len(pcts) >= 10 else 0,
|
||||
"20d": cumulative_return
|
||||
}
|
||||
|
||||
# 平均成交额
|
||||
avg_amount = np.mean(amounts) if amounts else 0
|
||||
|
||||
# 波动率(标准差)
|
||||
volatility = np.std(pcts) if len(pcts) > 1 else 0
|
||||
|
||||
sector_stats.append({
|
||||
"name": name,
|
||||
"returns": returns,
|
||||
"avg_amount": round(avg_amount, 2),
|
||||
"volatility": round(volatility, 2),
|
||||
"dates": data["dates"],
|
||||
"pcts": [round(p, 2) for p in pcts]
|
||||
})
|
||||
|
||||
# 按20日收益排序
|
||||
sector_stats.sort(key=lambda x: x["returns"]["20d"], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date": latest_date.isoformat(),
|
||||
"days": days,
|
||||
"sectors": sector_stats[:top_n]
|
||||
}
|
||||
|
||||
|
||||
def analyze_fund_flow(days: int = 5) -> Dict[str, Any]:
|
||||
"""分析资金流向(板块间流动)
|
||||
|
||||
Args:
|
||||
days: 分析天数
|
||||
|
||||
Returns:
|
||||
资金流向数据(桑基图格式)
|
||||
"""
|
||||
with get_session() as s:
|
||||
latest_date = s.execute(select(func.max(FundFlowDaily.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无资金流数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
# 查询资金流数据
|
||||
rows = s.execute(
|
||||
select(FundFlowDaily)
|
||||
.where(FundFlowDaily.date >= start_date)
|
||||
.order_by(FundFlowDaily.date, FundFlowDaily.name)
|
||||
).scalars().all()
|
||||
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
# 按板块聚合净流入
|
||||
flow_data = {}
|
||||
for row in rows:
|
||||
if row.name not in flow_data:
|
||||
flow_data[row.name] = 0
|
||||
flow_data[row.name] += float(row.net)
|
||||
|
||||
# 分类:流入 vs 流出
|
||||
inflows = [(k, v) for k, v in flow_data.items() if v > 0]
|
||||
outflows = [(k, abs(v)) for k, v in flow_data.items() if v < 0]
|
||||
|
||||
inflows.sort(key=lambda x: x[1], reverse=True)
|
||||
outflows.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 构造桑基图数据
|
||||
nodes = []
|
||||
links = []
|
||||
|
||||
# 流出节点(左侧)
|
||||
for i, (name, amount) in enumerate(outflows[:8]):
|
||||
nodes.append({"name": f"{name}(流出)"})
|
||||
# 流向"资金池"
|
||||
links.append({
|
||||
"source": len(nodes) - 1,
|
||||
"target": len(outflows[:8]), # 资金池索引
|
||||
"value": round(amount, 2)
|
||||
})
|
||||
|
||||
# 资金池(中间)
|
||||
nodes.append({"name": "资金池"})
|
||||
|
||||
# 流入节点(右侧)
|
||||
for i, (name, amount) in enumerate(inflows[:8]):
|
||||
nodes.append({"name": f"{name}(流入)"})
|
||||
# 从"资金池"流入
|
||||
links.append({
|
||||
"source": len(outflows[:8]), # 资金池索引
|
||||
"target": len(nodes) - 1,
|
||||
"value": round(amount, 2)
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date": latest_date.isoformat(),
|
||||
"days": days,
|
||||
"total_inflow": round(sum(v for _, v in inflows), 2),
|
||||
"total_outflow": round(sum(v for _, v in outflows), 2),
|
||||
"top_inflow": inflows[:8],
|
||||
"top_outflow": outflows[:8],
|
||||
"sankey": {
|
||||
"nodes": nodes,
|
||||
"links": links
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def analyze_lifecycle(sector_name: str, days: int = 60) -> Dict[str, Any]:
|
||||
"""分析板块生命周期
|
||||
|
||||
Args:
|
||||
sector_name: 板块名称
|
||||
days: 分析天数
|
||||
|
||||
Returns:
|
||||
生命周期判断
|
||||
"""
|
||||
with get_session() as s:
|
||||
latest_date = s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
rows = s.execute(
|
||||
select(SectorDaily)
|
||||
.where(
|
||||
and_(
|
||||
SectorDaily.name == sector_name,
|
||||
SectorDaily.date >= start_date
|
||||
)
|
||||
)
|
||||
.order_by(SectorDaily.date)
|
||||
).scalars().all()
|
||||
|
||||
if len(rows) < 20:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
# 提取数据
|
||||
dates = [r.date.isoformat() for r in rows]
|
||||
pcts = [float(r.pct) for r in rows]
|
||||
amounts = [float(r.amount) for r in rows]
|
||||
|
||||
# 计算指标
|
||||
# 1. 近期涨跌幅趋势
|
||||
recent_5 = sum(pcts[-5:])
|
||||
recent_10 = sum(pcts[-10:])
|
||||
recent_20 = sum(pcts[-20:])
|
||||
|
||||
# 2. 成交额趋势
|
||||
amount_5 = np.mean(amounts[-5:])
|
||||
amount_20 = np.mean(amounts[-20:])
|
||||
amount_change = (amount_5 / amount_20 - 1) * 100 if amount_20 > 0 else 0
|
||||
|
||||
# 3. 动量(价格变化加速度)
|
||||
momentum = recent_5 - recent_10
|
||||
|
||||
# 生命周期判断
|
||||
if recent_20 > 0 and momentum > 0 and amount_change > 20:
|
||||
phase = "启动期"
|
||||
description = "板块刚开始上涨,资金流入加速,可能是介入时机"
|
||||
elif recent_20 > 5 and recent_10 > recent_20 / 2 and amount_change > 0:
|
||||
phase = "加速期"
|
||||
description = "板块持续上涨且加速,成交活跃,主升浪阶段"
|
||||
elif recent_20 > 0 and momentum < 0:
|
||||
phase = "衰退期"
|
||||
description = "板块涨幅收窄或开始回调,资金开始流出,注意风险"
|
||||
elif recent_20 < -5:
|
||||
phase = "下跌期"
|
||||
description = "板块持续下跌,避免介入"
|
||||
else:
|
||||
phase = "震荡期"
|
||||
description = "板块横盘整理,方向不明"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"sector": sector_name,
|
||||
"phase": phase,
|
||||
"description": description,
|
||||
"metrics": {
|
||||
"return_5d": round(recent_5, 2),
|
||||
"return_10d": round(recent_10, 2),
|
||||
"return_20d": round(recent_20, 2),
|
||||
"momentum": round(momentum, 2),
|
||||
"amount_change": round(amount_change, 2)
|
||||
},
|
||||
"dates": dates,
|
||||
"pcts": [round(p, 2) for p in pcts]
|
||||
}
|
||||
|
||||
|
||||
def identify_leaders(sector_name: str, days: int = 20, limit: int = 10) -> Dict[str, Any]:
|
||||
"""识别板块龙头股
|
||||
|
||||
Args:
|
||||
sector_name: 板块名称
|
||||
days: 统计天数
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
龙头股列表
|
||||
"""
|
||||
# 注意:需要股票-板块映射表,这里简化为通过名称匹配
|
||||
# 实际应该有 stock_sector 映射表
|
||||
|
||||
with get_session() as s:
|
||||
# 获取最近N天表现最好的股票
|
||||
latest_date = s.execute(select(func.max(StockMetric.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无股票数据"}
|
||||
|
||||
# 查询高涨幅、高成交额股票
|
||||
rows = s.execute(
|
||||
select(StockMetric)
|
||||
.where(
|
||||
and_(
|
||||
StockMetric.date == latest_date,
|
||||
StockMetric.ret20 > 0,
|
||||
StockMetric.amount > 5 # 成交额 > 5亿
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
StockMetric.ret20.desc(),
|
||||
StockMetric.amount.desc()
|
||||
)
|
||||
.limit(limit * 3) # 多取一些,后续筛选
|
||||
).scalars().all()
|
||||
|
||||
# 简化:根据名称关键词匹配板块(实际应该查询映射表)
|
||||
sector_keywords = {
|
||||
"半导体": ["芯片", "半导体", "集成电路"],
|
||||
"新能源": ["新能源", "锂电", "光伏", "储能"],
|
||||
"医药": ["医药", "生物", "医疗", "药业"],
|
||||
"白酒": ["酒", "茅台", "五粮液"],
|
||||
"军工": ["军工", "航天", "航空", "兵器"],
|
||||
"AI": ["人工智能", "AI", "算力", "云计算"],
|
||||
}
|
||||
|
||||
keywords = sector_keywords.get(sector_name, [sector_name])
|
||||
|
||||
leaders = []
|
||||
for row in rows:
|
||||
if any(kw in row.name for kw in keywords):
|
||||
leaders.append({
|
||||
"code": row.code,
|
||||
"name": row.name,
|
||||
"close": round(row.close, 2),
|
||||
"pct": round(row.pct, 2),
|
||||
"ret5": round(row.ret5, 2),
|
||||
"ret20": round(row.ret20, 2),
|
||||
"amount": round(row.amount, 2),
|
||||
"vol_ratio": round(row.vol_ratio, 2)
|
||||
})
|
||||
if len(leaders) >= limit:
|
||||
break
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"sector": sector_name,
|
||||
"date": latest_date.isoformat(),
|
||||
"leaders": leaders
|
||||
}
|
||||
|
||||
|
||||
def analyze_correlation(days: int = 60, top_n: int = 20) -> Dict[str, Any]:
|
||||
"""板块联动性分析(相关系数矩阵)
|
||||
|
||||
Args:
|
||||
days: 计算天数
|
||||
top_n: 分析前N个板块
|
||||
|
||||
Returns:
|
||||
相关系数矩阵(热力图数据)
|
||||
"""
|
||||
with get_session() as s:
|
||||
latest_date = s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
rows = s.execute(
|
||||
select(SectorDaily)
|
||||
.where(SectorDaily.date >= start_date)
|
||||
.order_by(SectorDaily.date, SectorDaily.name)
|
||||
).scalars().all()
|
||||
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
# 按板块聚合涨跌幅
|
||||
sector_returns = {}
|
||||
for row in rows:
|
||||
if row.name not in sector_returns:
|
||||
sector_returns[row.name] = []
|
||||
sector_returns[row.name].append(float(row.pct))
|
||||
|
||||
# 筛选数据完整的板块
|
||||
valid_sectors = {k: v for k, v in sector_returns.items() if len(v) >= days * 0.8}
|
||||
|
||||
if len(valid_sectors) < 5:
|
||||
return {"ok": False, "msg": "有效板块不足"}
|
||||
|
||||
# 选择前N个板块(按最近涨幅)
|
||||
sector_list = []
|
||||
for name, rets in valid_sectors.items():
|
||||
recent_return = sum(rets[-min(10, len(rets)):])
|
||||
sector_list.append((name, recent_return, rets))
|
||||
|
||||
sector_list.sort(key=lambda x: x[1], reverse=True)
|
||||
selected = sector_list[:top_n]
|
||||
|
||||
# 计算相关系数矩阵
|
||||
names = [s[0] for s in selected]
|
||||
returns_matrix = np.array([s[2][:days] for s in selected])
|
||||
|
||||
# 填充短数据(用0)
|
||||
max_len = max(len(r) for r in returns_matrix)
|
||||
padded = []
|
||||
for r in returns_matrix:
|
||||
if len(r) < max_len:
|
||||
r = list(r) + [0] * (max_len - len(r))
|
||||
padded.append(r[:max_len])
|
||||
|
||||
returns_matrix = np.array(padded)
|
||||
|
||||
# 计算相关系数
|
||||
corr_matrix = np.corrcoef(returns_matrix)
|
||||
|
||||
# 转换为热力图数据
|
||||
heatmap_data = []
|
||||
for i in range(len(names)):
|
||||
for j in range(len(names)):
|
||||
heatmap_data.append({
|
||||
"x": j,
|
||||
"y": i,
|
||||
"value": round(float(corr_matrix[i][j]), 3)
|
||||
})
|
||||
|
||||
# 找出高度相关的板块对(相关系数 > 0.7)
|
||||
high_corr = []
|
||||
for i in range(len(names)):
|
||||
for j in range(i + 1, len(names)):
|
||||
corr = float(corr_matrix[i][j])
|
||||
if corr > 0.7:
|
||||
high_corr.append({
|
||||
"sector1": names[i],
|
||||
"sector2": names[j],
|
||||
"correlation": round(corr, 3)
|
||||
})
|
||||
|
||||
high_corr.sort(key=lambda x: x["correlation"], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"sectors": names,
|
||||
"matrix": corr_matrix.tolist(),
|
||||
"heatmap": heatmap_data,
|
||||
"high_correlation": high_corr[:10]
|
||||
}
|
||||
|
||||
|
||||
def get_rotation_summary() -> Dict[str, Any]:
|
||||
"""获取板块轮动综合摘要
|
||||
|
||||
Returns:
|
||||
轮动摘要
|
||||
"""
|
||||
# 获取最强和最弱板块
|
||||
trend = get_sector_trend(days=10, top_n=20)
|
||||
if not trend.get("ok"):
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
sectors = trend["sectors"]
|
||||
strongest = sectors[:3]
|
||||
weakest = sectors[-3:]
|
||||
|
||||
# 资金流向
|
||||
flow = analyze_fund_flow(days=5)
|
||||
|
||||
summary = {
|
||||
"ok": True,
|
||||
"date": trend["date"],
|
||||
"strongest_sectors": [
|
||||
{
|
||||
"name": s["name"],
|
||||
"return_10d": s["returns"]["10d"]
|
||||
} for s in strongest
|
||||
],
|
||||
"weakest_sectors": [
|
||||
{
|
||||
"name": s["name"],
|
||||
"return_10d": s["returns"]["10d"]
|
||||
} for s in weakest
|
||||
],
|
||||
"fund_flow": {
|
||||
"top_inflow": flow.get("top_inflow", [])[:3] if flow.get("ok") else [],
|
||||
"top_outflow": flow.get("top_outflow", [])[:3] if flow.get("ok") else []
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
556
backend/sentiment_monitor.py
Normal file
556
backend/sentiment_monitor.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""社区情绪监控 — 爬取分析东方财富/雪球热帖,量化散户情绪。
|
||||
|
||||
功能:
|
||||
1. 爬取社区热帖
|
||||
2. 情绪分析(乐观/悲观)
|
||||
3. 热议股票排行
|
||||
4. 关键词提取和词云
|
||||
5. 情绪与股价相关性分析
|
||||
"""
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from collections import Counter, defaultdict
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import jieba
|
||||
import jieba.analyse
|
||||
from sqlalchemy import select, func, and_, desc
|
||||
|
||||
from db import get_session
|
||||
from models import SocialPost, SentimentIndex, DailyQuote, StockMetric
|
||||
|
||||
# 情绪关键词库
|
||||
BULLISH_KEYWORDS = [
|
||||
'看多', '看好', '买入', '加仓', '抄底', '突破', '上涨', '暴涨', '牛市',
|
||||
'利好', '反弹', '强势', '拉升', '涨停', '走强', '看涨', '做多'
|
||||
]
|
||||
|
||||
BEARISH_KEYWORDS = [
|
||||
'看空', '看跌', '卖出', '减仓', '止损', '下跌', '暴跌', '熊市',
|
||||
'利空', '回调', '弱势', '下杀', '跌停', '走弱', '做空', '被套'
|
||||
]
|
||||
|
||||
# 停用词
|
||||
STOP_WORDS = set([
|
||||
'的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一',
|
||||
'一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有',
|
||||
'看', '好', '自己', '这', '那', '以', '为', '而', '能', '他', '对', '于'
|
||||
])
|
||||
|
||||
|
||||
def crawl_eastmoney_hot(limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""爬取东方财富热帖(简化版,实际需要处理反爬)
|
||||
|
||||
注意:由于反爬限制,这里返回模拟数据
|
||||
实际生产环境需要:
|
||||
1. 使用代理IP
|
||||
2. 模拟浏览器headers
|
||||
3. 控制请求频率
|
||||
4. 处理验证码
|
||||
"""
|
||||
# 模拟数据(实际应该爬取真实数据)
|
||||
mock_posts = [
|
||||
{
|
||||
'source': 'eastmoney',
|
||||
'post_id': f'em_{i}',
|
||||
'title': f'模拟帖子{i}:今天大盘要反弹了',
|
||||
'content': '技术分析显示底部信号明显,建议逢低买入',
|
||||
'author': f'用户{i}',
|
||||
'comment_count': 100 + i * 10,
|
||||
'view_count': 1000 + i * 100,
|
||||
}
|
||||
for i in range(limit)
|
||||
]
|
||||
|
||||
return mock_posts
|
||||
|
||||
|
||||
def crawl_xueqiu_hot(limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""爬取雪球热帖(简化版)"""
|
||||
# 雪球API(需要cookie和token)
|
||||
# 实际使用需要登录后获取token
|
||||
|
||||
mock_posts = [
|
||||
{
|
||||
'source': 'xueqiu',
|
||||
'post_id': f'xq_{i}',
|
||||
'title': f'雪球热议{i}:半导体板块分析',
|
||||
'content': '从产业链角度看,半导体景气度回升',
|
||||
'author': f'雪球用户{i}',
|
||||
'comment_count': 50 + i * 5,
|
||||
'view_count': 500 + i * 50,
|
||||
}
|
||||
for i in range(limit)
|
||||
]
|
||||
|
||||
return mock_posts
|
||||
|
||||
|
||||
def analyze_sentiment(text: str) -> str:
|
||||
"""分析文本情绪
|
||||
|
||||
Args:
|
||||
text: 待分析文本
|
||||
|
||||
Returns:
|
||||
情绪标签:bullish/bearish/neutral
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
|
||||
bullish_score = sum(1 for kw in BULLISH_KEYWORDS if kw in text_lower)
|
||||
bearish_score = sum(1 for kw in BEARISH_KEYWORDS if kw in text_lower)
|
||||
|
||||
if bullish_score > bearish_score and bullish_score >= 2:
|
||||
return 'bullish'
|
||||
elif bearish_score > bullish_score and bearish_score >= 2:
|
||||
return 'bearish'
|
||||
else:
|
||||
return 'neutral'
|
||||
|
||||
|
||||
def extract_keywords(text: str, top_n: int = 10) -> List[str]:
|
||||
"""提取关键词
|
||||
|
||||
Args:
|
||||
text: 文本内容
|
||||
top_n: 返回前N个关键词
|
||||
|
||||
Returns:
|
||||
关键词列表
|
||||
"""
|
||||
# 使用jieba提取关键词
|
||||
keywords = jieba.analyse.extract_tags(text, topK=top_n, withWeight=False)
|
||||
|
||||
# 过滤停用词
|
||||
keywords = [kw for kw in keywords if kw not in STOP_WORDS and len(kw) > 1]
|
||||
|
||||
return keywords[:top_n]
|
||||
|
||||
|
||||
def extract_stock_codes(text: str) -> List[str]:
|
||||
"""从文本中提取股票代码
|
||||
|
||||
Args:
|
||||
text: 文本内容
|
||||
|
||||
Returns:
|
||||
股票代码列表
|
||||
"""
|
||||
# 匹配6位数字的股票代码
|
||||
pattern = r'\b[036]\d{5}\b'
|
||||
codes = re.findall(pattern, text)
|
||||
return list(set(codes))
|
||||
|
||||
|
||||
def collect_posts(limit_per_source: int = 50) -> Dict[str, Any]:
|
||||
"""采集社区帖子
|
||||
|
||||
Args:
|
||||
limit_per_source: 每个来源采集数量
|
||||
|
||||
Returns:
|
||||
采集结果
|
||||
"""
|
||||
all_posts = []
|
||||
|
||||
# 采集东方财富
|
||||
try:
|
||||
em_posts = crawl_eastmoney_hot(limit_per_source)
|
||||
all_posts.extend(em_posts)
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] crawl error: {e}")
|
||||
|
||||
# 采集雪球
|
||||
try:
|
||||
xq_posts = crawl_xueqiu_hot(limit_per_source)
|
||||
all_posts.extend(xq_posts)
|
||||
except Exception as e:
|
||||
print(f"[xueqiu] crawl error: {e}")
|
||||
|
||||
# 分析并存储
|
||||
saved_count = 0
|
||||
with get_session() as s:
|
||||
for post in all_posts:
|
||||
# 检查是否已存在
|
||||
exists = s.execute(
|
||||
select(SocialPost).where(SocialPost.post_id == post['post_id'])
|
||||
).scalar_one_or_none()
|
||||
|
||||
if exists:
|
||||
continue
|
||||
|
||||
# 情绪分析
|
||||
text = post['title'] + ' ' + post.get('content', '')
|
||||
sentiment = analyze_sentiment(text)
|
||||
|
||||
# 提取关键词
|
||||
keywords = extract_keywords(text, top_n=5)
|
||||
|
||||
# 提取股票代码
|
||||
codes = extract_stock_codes(text)
|
||||
code = codes[0] if codes else ''
|
||||
|
||||
# 存储
|
||||
record = SocialPost(
|
||||
source=post['source'],
|
||||
post_id=post['post_id'],
|
||||
code=code,
|
||||
title=post['title'],
|
||||
content=post.get('content', ''),
|
||||
author=post.get('author', ''),
|
||||
comment_count=post.get('comment_count', 0),
|
||||
view_count=post.get('view_count', 0),
|
||||
sentiment=sentiment,
|
||||
keywords=','.join(keywords)
|
||||
)
|
||||
s.add(record)
|
||||
saved_count += 1
|
||||
|
||||
s.commit()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'collected': len(all_posts),
|
||||
'saved': saved_count
|
||||
}
|
||||
|
||||
|
||||
def calculate_sentiment_index(date: Optional[dt.date] = None) -> Dict[str, Any]:
|
||||
"""计算情绪指数
|
||||
|
||||
Args:
|
||||
date: 统计日期,None表示今天
|
||||
|
||||
Returns:
|
||||
情绪指数数据
|
||||
"""
|
||||
if date is None:
|
||||
date = dt.date.today()
|
||||
|
||||
start = dt.datetime.combine(date, dt.time.min)
|
||||
end = dt.datetime.combine(date, dt.time.max)
|
||||
|
||||
with get_session() as s:
|
||||
# 统计各情绪数量
|
||||
posts = s.execute(
|
||||
select(SocialPost)
|
||||
.where(
|
||||
and_(
|
||||
SocialPost.created_at >= start,
|
||||
SocialPost.created_at <= end
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
if not posts:
|
||||
return {'ok': False, 'msg': '暂无数据'}
|
||||
|
||||
bullish_count = sum(1 for p in posts if p.sentiment == 'bullish')
|
||||
bearish_count = sum(1 for p in posts if p.sentiment == 'bearish')
|
||||
neutral_count = sum(1 for p in posts if p.sentiment == 'neutral')
|
||||
total = len(posts)
|
||||
|
||||
bullish_ratio = bullish_count / total * 100 if total > 0 else 0
|
||||
|
||||
# 提取热门关键词
|
||||
all_keywords = []
|
||||
for p in posts:
|
||||
if p.keywords:
|
||||
all_keywords.extend(p.keywords.split(','))
|
||||
|
||||
keyword_counter = Counter(all_keywords)
|
||||
top_keywords = [
|
||||
{'word': kw, 'count': cnt}
|
||||
for kw, cnt in keyword_counter.most_common(20)
|
||||
]
|
||||
|
||||
# 存储情绪指数
|
||||
index_record = s.execute(
|
||||
select(SentimentIndex).where(SentimentIndex.date == date)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if index_record:
|
||||
index_record.bullish_count = bullish_count
|
||||
index_record.bearish_count = bearish_count
|
||||
index_record.neutral_count = neutral_count
|
||||
index_record.bullish_ratio = bullish_ratio
|
||||
index_record.total_posts = total
|
||||
index_record.top_keywords = json.dumps(top_keywords, ensure_ascii=False)
|
||||
index_record.updated_at = dt.datetime.now()
|
||||
else:
|
||||
index_record = SentimentIndex(
|
||||
date=date,
|
||||
bullish_count=bullish_count,
|
||||
bearish_count=bearish_count,
|
||||
neutral_count=neutral_count,
|
||||
bullish_ratio=bullish_ratio,
|
||||
total_posts=total,
|
||||
top_keywords=json.dumps(top_keywords, ensure_ascii=False)
|
||||
)
|
||||
s.add(index_record)
|
||||
|
||||
s.commit()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'date': date.isoformat(),
|
||||
'bullish_count': bullish_count,
|
||||
'bearish_count': bearish_count,
|
||||
'neutral_count': neutral_count,
|
||||
'bullish_ratio': round(bullish_ratio, 2),
|
||||
'total_posts': total,
|
||||
'top_keywords': top_keywords
|
||||
}
|
||||
|
||||
|
||||
def get_hot_stocks(days: int = 1, limit: int = 20) -> Dict[str, Any]:
|
||||
"""获取热议股票排行
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
热议股票列表
|
||||
"""
|
||||
since = dt.datetime.now() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
# 按股票代码分组统计
|
||||
stmt = (
|
||||
select(
|
||||
SocialPost.code,
|
||||
func.count().label('post_count'),
|
||||
func.sum(SocialPost.comment_count).label('total_comments'),
|
||||
func.sum(SocialPost.view_count).label('total_views')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
SocialPost.code != '',
|
||||
SocialPost.created_at >= since
|
||||
)
|
||||
)
|
||||
.group_by(SocialPost.code)
|
||||
.order_by(desc('post_count'))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = s.execute(stmt).all()
|
||||
|
||||
if not rows:
|
||||
return {'ok': False, 'msg': '暂无数据'}
|
||||
|
||||
# 获取股票名称和最新价格
|
||||
codes = [r.code for r in rows]
|
||||
metrics = {}
|
||||
for m in s.execute(
|
||||
select(StockMetric)
|
||||
.where(StockMetric.code.in_(codes))
|
||||
).scalars():
|
||||
metrics[m.code] = {
|
||||
'name': m.name,
|
||||
'close': m.close,
|
||||
'pct': m.pct
|
||||
}
|
||||
|
||||
results = []
|
||||
for r in rows:
|
||||
info = metrics.get(r.code, {'name': r.code, 'close': 0, 'pct': 0})
|
||||
results.append({
|
||||
'code': r.code,
|
||||
'name': info['name'],
|
||||
'post_count': r.post_count,
|
||||
'total_comments': r.total_comments or 0,
|
||||
'total_views': r.total_views or 0,
|
||||
'heat_score': r.post_count * 10 + (r.total_comments or 0),
|
||||
'close': info['close'],
|
||||
'pct': info['pct']
|
||||
})
|
||||
|
||||
# 按热度评分排序
|
||||
results.sort(key=lambda x: x['heat_score'], reverse=True)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'days': days,
|
||||
'stocks': results
|
||||
}
|
||||
|
||||
|
||||
def get_sentiment_history(days: int = 30) -> Dict[str, Any]:
|
||||
"""获取情绪指数历史
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
历史数据
|
||||
"""
|
||||
since = dt.date.today() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(SentimentIndex)
|
||||
.where(SentimentIndex.date >= since)
|
||||
.order_by(SentimentIndex.date)
|
||||
).scalars().all()
|
||||
|
||||
if not rows:
|
||||
return {'ok': False, 'msg': '暂无历史数据'}
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'dates': [r.date.isoformat() for r in rows],
|
||||
'bullish_ratio': [round(r.bullish_ratio, 2) for r in rows],
|
||||
'total_posts': [r.total_posts for r in rows]
|
||||
}
|
||||
|
||||
|
||||
def analyze_sentiment_correlation(code: str, days: int = 60) -> Dict[str, Any]:
|
||||
"""分析情绪与股价相关性
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
days: 分析天数
|
||||
|
||||
Returns:
|
||||
相关性分析结果
|
||||
"""
|
||||
since = dt.date.today() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
# 获取该股票的讨论量和情绪
|
||||
posts = s.execute(
|
||||
select(SocialPost)
|
||||
.where(
|
||||
and_(
|
||||
SocialPost.code == code,
|
||||
func.date(SocialPost.created_at) >= since
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
if not posts:
|
||||
return {'ok': False, 'msg': '该股票暂无社区数据'}
|
||||
|
||||
# 按日期聚合
|
||||
daily_sentiment = defaultdict(lambda: {'bullish': 0, 'bearish': 0, 'neutral': 0, 'total': 0})
|
||||
for p in posts:
|
||||
date = p.created_at.date()
|
||||
daily_sentiment[date][p.sentiment] += 1
|
||||
daily_sentiment[date]['total'] += 1
|
||||
|
||||
# 获取股价数据
|
||||
prices = {}
|
||||
for q in s.execute(
|
||||
select(DailyQuote)
|
||||
.where(
|
||||
and_(
|
||||
DailyQuote.code == code,
|
||||
DailyQuote.date >= since
|
||||
)
|
||||
)
|
||||
.order_by(DailyQuote.date)
|
||||
).scalars():
|
||||
prices[q.date] = {
|
||||
'close': float(q.close),
|
||||
'pct': ((float(q.close) - float(q.open)) / float(q.open) * 100) if q.open > 0 else 0
|
||||
}
|
||||
|
||||
if not prices:
|
||||
return {'ok': False, 'msg': '缺少股价数据'}
|
||||
|
||||
# 计算相关性(简化版)
|
||||
dates = sorted(set(daily_sentiment.keys()) & set(prices.keys()))
|
||||
|
||||
if len(dates) < 10:
|
||||
return {'ok': False, 'msg': '数据点不足'}
|
||||
|
||||
sentiment_scores = []
|
||||
price_changes = []
|
||||
|
||||
for date in dates:
|
||||
s_data = daily_sentiment[date]
|
||||
bullish_ratio = s_data['bullish'] / s_data['total'] * 100 if s_data['total'] > 0 else 50
|
||||
sentiment_scores.append(bullish_ratio)
|
||||
|
||||
price_changes.append(prices[date]['pct'])
|
||||
|
||||
# 计算相关系数(简化版)
|
||||
import numpy as np
|
||||
if len(sentiment_scores) > 1:
|
||||
correlation = np.corrcoef(sentiment_scores, price_changes)[0, 1]
|
||||
else:
|
||||
correlation = 0
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'code': code,
|
||||
'days': days,
|
||||
'data_points': len(dates),
|
||||
'correlation': round(float(correlation), 3),
|
||||
'interpretation': _interpret_correlation(correlation),
|
||||
'dates': [d.isoformat() for d in dates],
|
||||
'sentiment_scores': [round(s, 2) for s in sentiment_scores],
|
||||
'price_changes': [round(p, 2) for p in price_changes]
|
||||
}
|
||||
|
||||
|
||||
def _interpret_correlation(corr: float) -> str:
|
||||
"""解释相关系数"""
|
||||
if corr > 0.7:
|
||||
return '强正相关:情绪高涨时股价往往上涨'
|
||||
elif corr > 0.3:
|
||||
return '中度正相关:情绪与股价有一定同步性'
|
||||
elif corr > -0.3:
|
||||
return '弱相关:情绪与股价关系不明显'
|
||||
elif corr > -0.7:
|
||||
return '中度负相关:情绪高涨时股价反而下跌(反向指标)'
|
||||
else:
|
||||
return '强负相关:典型反向指标,情绪越乐观越要警惕'
|
||||
|
||||
|
||||
def get_keyword_cloud(days: int = 7, top_n: int = 50) -> Dict[str, Any]:
|
||||
"""获取关键词云数据
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
top_n: 返回前N个关键词
|
||||
|
||||
Returns:
|
||||
词云数据
|
||||
"""
|
||||
since = dt.datetime.now() - dt.timedelta(days=days)
|
||||
|
||||
with get_session() as s:
|
||||
posts = s.execute(
|
||||
select(SocialPost)
|
||||
.where(SocialPost.created_at >= since)
|
||||
).scalars().all()
|
||||
|
||||
if not posts:
|
||||
return {'ok': False, 'msg': '暂无数据'}
|
||||
|
||||
# 收集所有关键词
|
||||
all_keywords = []
|
||||
for p in posts:
|
||||
if p.keywords:
|
||||
all_keywords.extend(p.keywords.split(','))
|
||||
|
||||
# 统计词频
|
||||
keyword_counter = Counter(all_keywords)
|
||||
|
||||
# 格式化为词云数据
|
||||
wordcloud_data = [
|
||||
{'name': kw, 'value': cnt}
|
||||
for kw, cnt in keyword_counter.most_common(top_n)
|
||||
]
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'days': days,
|
||||
'keywords': wordcloud_data
|
||||
}
|
||||
|
||||
390
backend/smart_selector.py
Normal file
390
backend/smart_selector.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""智能选股引擎 — 可视化选股器、策略保存、回测验证。
|
||||
|
||||
功能:
|
||||
1. 多条件组合选股
|
||||
2. 选股策略保存/加载
|
||||
3. 选股结果历史回测
|
||||
4. 选股结果对比(新入选/退出)
|
||||
5. 条件预警
|
||||
"""
|
||||
import datetime as dt
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
import numpy as np
|
||||
|
||||
from db import get_session
|
||||
from models import StockMetric, DailyQuote, Security
|
||||
|
||||
|
||||
class Condition:
|
||||
"""选股条件"""
|
||||
|
||||
def __init__(self, field: str, operator: str, value: Any):
|
||||
self.field = field
|
||||
self.operator = operator
|
||||
self.value = value
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"field": self.field,
|
||||
"operator": self.operator,
|
||||
"value": self.value
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict):
|
||||
return cls(data["field"], data["operator"], data["value"])
|
||||
|
||||
def to_sql(self, model):
|
||||
"""转换为 SQLAlchemy 查询条件"""
|
||||
field = getattr(model, self.field, None)
|
||||
if field is None:
|
||||
return None
|
||||
|
||||
op = self.operator
|
||||
val = self.value
|
||||
|
||||
if op == "==":
|
||||
return field == val
|
||||
elif op == ">":
|
||||
return field > val
|
||||
elif op == ">=":
|
||||
return field >= val
|
||||
elif op == "<":
|
||||
return field < val
|
||||
elif op == "<=":
|
||||
return field <= val
|
||||
elif op == "between":
|
||||
return and_(field >= val[0], field <= val[1])
|
||||
elif op == "in":
|
||||
return field.in_(val)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class Strategy:
|
||||
"""选股策略"""
|
||||
|
||||
def __init__(self, name: str, description: str = ""):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.conditions: List[Condition] = []
|
||||
self.logic = "and" # and / or
|
||||
|
||||
def add_condition(self, field: str, operator: str, value: Any):
|
||||
"""添加条件"""
|
||||
self.conditions.append(Condition(field, operator, value))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"logic": self.logic,
|
||||
"conditions": [c.to_dict() for c in self.conditions]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict):
|
||||
strategy = cls(data["name"], data.get("description", ""))
|
||||
strategy.logic = data.get("logic", "and")
|
||||
for cond in data.get("conditions", []):
|
||||
strategy.conditions.append(Condition.from_dict(cond))
|
||||
return strategy
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""序列化为JSON"""
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str):
|
||||
"""从JSON反序列化"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
|
||||
def run_selector(strategy: Strategy, date: Optional[dt.date] = None) -> Dict[str, Any]:
|
||||
"""执行选股
|
||||
|
||||
Args:
|
||||
strategy: 选股策略
|
||||
date: 选股日期,None表示最新
|
||||
|
||||
Returns:
|
||||
选股结果
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 确定日期
|
||||
if date is None:
|
||||
date = s.execute(select(func.max(StockMetric.date))).scalar()
|
||||
|
||||
if not date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
# 构建查询
|
||||
query = select(StockMetric).where(StockMetric.date == date)
|
||||
|
||||
# 应用条件
|
||||
sql_conditions = []
|
||||
for cond in strategy.conditions:
|
||||
sql_cond = cond.to_sql(StockMetric)
|
||||
if sql_cond is not None:
|
||||
sql_conditions.append(sql_cond)
|
||||
|
||||
if sql_conditions:
|
||||
if strategy.logic == "and":
|
||||
query = query.where(and_(*sql_conditions))
|
||||
else: # or
|
||||
query = query.where(or_(*sql_conditions))
|
||||
|
||||
# 执行查询
|
||||
rows = s.execute(query).scalars().all()
|
||||
|
||||
# 格式化结果
|
||||
results = [{
|
||||
"code": r.code,
|
||||
"name": r.name,
|
||||
"close": round(r.close, 2),
|
||||
"pct": round(r.pct, 2),
|
||||
"ret5": round(r.ret5, 2),
|
||||
"ret20": round(r.ret20, 2),
|
||||
"vol_ratio": round(r.vol_ratio, 2),
|
||||
"rsi14": round(r.rsi14, 2),
|
||||
"pos60": round(r.pos60 * 100, 1),
|
||||
"amount": round(r.amount, 2),
|
||||
"ma_bull": r.ma_bull,
|
||||
"macd_gold": r.macd_gold
|
||||
} for r in rows]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date": date.isoformat(),
|
||||
"strategy": strategy.name,
|
||||
"count": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
def backtest_selector(strategy: Strategy, days: int = 60) -> Dict[str, Any]:
|
||||
"""选股策略回测
|
||||
|
||||
Args:
|
||||
strategy: 选股策略
|
||||
days: 回测天数
|
||||
|
||||
Returns:
|
||||
回测结果
|
||||
"""
|
||||
with get_session() as s:
|
||||
# 获取最近N个交易日
|
||||
latest_date = s.execute(select(func.max(StockMetric.date))).scalar()
|
||||
if not latest_date:
|
||||
return {"ok": False, "msg": "暂无数据"}
|
||||
|
||||
start_date = latest_date - dt.timedelta(days=days)
|
||||
|
||||
# 获取这段时间内的所有交易日
|
||||
dates = s.execute(
|
||||
select(StockMetric.date)
|
||||
.where(StockMetric.date >= start_date)
|
||||
.group_by(StockMetric.date)
|
||||
.order_by(StockMetric.date)
|
||||
).scalars().all()
|
||||
|
||||
if len(dates) < 5:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
# 逐日选股并统计后续N日收益
|
||||
daily_results = []
|
||||
for i, date in enumerate(dates[:-5]): # 至少保留5日用于计算收益
|
||||
# 执行选股
|
||||
result = run_selector(strategy, date)
|
||||
if not result["ok"] or not result["results"]:
|
||||
continue
|
||||
|
||||
selected_codes = [r["code"] for r in result["results"]]
|
||||
|
||||
# 查询5日后的收益
|
||||
future_date = dates[min(i + 5, len(dates) - 1)]
|
||||
|
||||
with get_session() as s:
|
||||
# 获取选中股票5日后的表现
|
||||
future_rows = s.execute(
|
||||
select(DailyQuote.code, DailyQuote.close)
|
||||
.where(
|
||||
and_(
|
||||
DailyQuote.code.in_(selected_codes),
|
||||
DailyQuote.date == future_date
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
current_rows = s.execute(
|
||||
select(DailyQuote.code, DailyQuote.close)
|
||||
.where(
|
||||
and_(
|
||||
DailyQuote.code.in_(selected_codes),
|
||||
DailyQuote.date == date
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
# 计算收益
|
||||
current_prices = {r.code: float(r.close) for r in current_rows}
|
||||
future_prices = {r.code: float(r.close) for r in future_rows}
|
||||
|
||||
returns = []
|
||||
for code in selected_codes:
|
||||
if code in current_prices and code in future_prices:
|
||||
ret = (future_prices[code] / current_prices[code] - 1) * 100
|
||||
returns.append(ret)
|
||||
|
||||
if returns:
|
||||
daily_results.append({
|
||||
"date": date.isoformat(),
|
||||
"count": len(selected_codes),
|
||||
"avg_return": round(np.mean(returns), 2),
|
||||
"median_return": round(np.median(returns), 2),
|
||||
"win_rate": round(sum(1 for r in returns if r > 0) / len(returns) * 100, 1),
|
||||
"max_return": round(max(returns), 2),
|
||||
"min_return": round(min(returns), 2)
|
||||
})
|
||||
|
||||
if not daily_results:
|
||||
return {"ok": False, "msg": "回测数据不足"}
|
||||
|
||||
# 汇总统计
|
||||
avg_returns = [r["avg_return"] for r in daily_results]
|
||||
win_rates = [r["win_rate"] for r in daily_results]
|
||||
|
||||
summary = {
|
||||
"total_days": len(daily_results),
|
||||
"avg_return": round(np.mean(avg_returns), 2),
|
||||
"avg_win_rate": round(np.mean(win_rates), 1),
|
||||
"best_day": max(daily_results, key=lambda x: x["avg_return"]),
|
||||
"worst_day": min(daily_results, key=lambda x: x["avg_return"])
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"strategy": strategy.name,
|
||||
"days": days,
|
||||
"summary": summary,
|
||||
"daily": daily_results
|
||||
}
|
||||
|
||||
|
||||
def compare_results(date1: dt.date, date2: dt.date, strategy: Strategy) -> Dict[str, Any]:
|
||||
"""对比两个日期的选股结果
|
||||
|
||||
Args:
|
||||
date1: 日期1(通常是昨日)
|
||||
date2: 日期2(通常是今日)
|
||||
strategy: 选股策略
|
||||
|
||||
Returns:
|
||||
对比结果
|
||||
"""
|
||||
result1 = run_selector(strategy, date1)
|
||||
result2 = run_selector(strategy, date2)
|
||||
|
||||
if not result1["ok"] or not result2["ok"]:
|
||||
return {"ok": False, "msg": "数据不足"}
|
||||
|
||||
codes1 = set(r["code"] for r in result1["results"])
|
||||
codes2 = set(r["code"] for r in result2["results"])
|
||||
|
||||
# 新入选
|
||||
new_in = codes2 - codes1
|
||||
# 退出
|
||||
dropped = codes1 - codes2
|
||||
# 持续入选
|
||||
continued = codes1 & codes2
|
||||
|
||||
# 获取详细信息
|
||||
new_in_stocks = [r for r in result2["results"] if r["code"] in new_in]
|
||||
dropped_stocks = [r for r in result1["results"] if r["code"] in dropped]
|
||||
continued_stocks = [r for r in result2["results"] if r["code"] in continued]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date1": date1.isoformat(),
|
||||
"date2": date2.isoformat(),
|
||||
"count1": len(codes1),
|
||||
"count2": len(codes2),
|
||||
"new_in": {
|
||||
"count": len(new_in),
|
||||
"stocks": new_in_stocks
|
||||
},
|
||||
"dropped": {
|
||||
"count": len(dropped),
|
||||
"stocks": dropped_stocks
|
||||
},
|
||||
"continued": {
|
||||
"count": len(continued),
|
||||
"stocks": continued_stocks
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 预设策略
|
||||
PRESET_STRATEGIES = {
|
||||
"momentum": Strategy("动量突破", "短期强势+放量"),
|
||||
"value": Strategy("价值洼地", "超跌低位+基本面支撑"),
|
||||
"growth": Strategy("成长加速", "持续上涨+量价齐升"),
|
||||
"reversal": Strategy("反转抄底", "超跌企稳+技术反转")
|
||||
}
|
||||
|
||||
# 动量突破
|
||||
PRESET_STRATEGIES["momentum"].add_condition("ret5", ">", 10)
|
||||
PRESET_STRATEGIES["momentum"].add_condition("vol_ratio", ">", 2)
|
||||
PRESET_STRATEGIES["momentum"].add_condition("rsi14", "<", 80)
|
||||
|
||||
# 价值洼地
|
||||
PRESET_STRATEGIES["value"].add_condition("pos60", "<", 0.3)
|
||||
PRESET_STRATEGIES["value"].add_condition("pct", ">", 0)
|
||||
PRESET_STRATEGIES["value"].add_condition("amount", ">", 5)
|
||||
|
||||
# 成长加速
|
||||
PRESET_STRATEGIES["growth"].add_condition("ret20", ">", 15)
|
||||
PRESET_STRATEGIES["growth"].add_condition("ma_bull", "==", True)
|
||||
PRESET_STRATEGIES["growth"].add_condition("up_streak", ">=", 2)
|
||||
|
||||
# 反转抄底
|
||||
PRESET_STRATEGIES["reversal"].add_condition("ret20", "<", -15)
|
||||
PRESET_STRATEGIES["reversal"].add_condition("rsi14", "<", 30)
|
||||
PRESET_STRATEGIES["reversal"].add_condition("pct", ">", 2)
|
||||
|
||||
|
||||
def get_preset_strategies() -> List[Dict[str, Any]]:
|
||||
"""获取预设策略列表"""
|
||||
return [
|
||||
{
|
||||
"id": key,
|
||||
"name": strategy.name,
|
||||
"description": strategy.description,
|
||||
"conditions_count": len(strategy.conditions)
|
||||
}
|
||||
for key, strategy in PRESET_STRATEGIES.items()
|
||||
]
|
||||
|
||||
|
||||
def get_available_fields() -> List[Dict[str, Any]]:
|
||||
"""获取可用的选股字段"""
|
||||
return [
|
||||
{"field": "close", "name": "现价", "type": "number", "unit": "元"},
|
||||
{"field": "pct", "name": "涨跌幅", "type": "number", "unit": "%"},
|
||||
{"field": "ret5", "name": "5日涨幅", "type": "number", "unit": "%"},
|
||||
{"field": "ret20", "name": "20日涨幅", "type": "number", "unit": "%"},
|
||||
{"field": "ret60", "name": "60日涨幅", "type": "number", "unit": "%"},
|
||||
{"field": "ma5", "name": "MA5", "type": "number", "unit": "元"},
|
||||
{"field": "ma10", "name": "MA10", "type": "number", "unit": "元"},
|
||||
{"field": "ma20", "name": "MA20", "type": "number", "unit": "元"},
|
||||
{"field": "ma60", "name": "MA60", "type": "number", "unit": "元"},
|
||||
{"field": "vol_ratio", "name": "量比", "type": "number", "unit": ""},
|
||||
{"field": "rsi14", "name": "RSI", "type": "number", "unit": ""},
|
||||
{"field": "pos60", "name": "60日分位", "type": "number", "unit": ""},
|
||||
{"field": "amount", "name": "成交额", "type": "number", "unit": "亿"},
|
||||
{"field": "up_streak", "name": "连涨天数", "type": "number", "unit": "天"},
|
||||
{"field": "ma_bull", "name": "均线多头", "type": "boolean", "unit": ""},
|
||||
{"field": "macd_gold", "name": "MACD金叉", "type": "boolean", "unit": ""}
|
||||
]
|
||||
130
prototype/ai-chat.js
vendored
Normal file
130
prototype/ai-chat.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
// AI 对话式分析
|
||||
|
||||
let _SESSION_ID = 'session_' + Date.now();
|
||||
let _CHAT_MESSAGES = [];
|
||||
|
||||
// 添加 AI 对话菜单项
|
||||
if (!MENU.find(g => g.children.find(c => c.id === 'ai-chat'))) {
|
||||
const aiMenu = MENU.find(g => g.name === 'AI 分析');
|
||||
if (aiMenu) {
|
||||
aiMenu.children.unshift({ id: 'ai-chat', name: 'AI 对话助手' });
|
||||
}
|
||||
}
|
||||
|
||||
VIEWS['ai-chat'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel" style="display:flex;flex-direction:column;height:calc(100vh - 120px)">
|
||||
<div class="panel-head">
|
||||
<span class="bar"></span>AI 对话助手
|
||||
<span class="sub">自然语言交互</span>
|
||||
<button id="chat-clear" class="btn-run" style="margin-left:auto;background:#5a2630;border-color:#5a2630">清空对话</button>
|
||||
</div>
|
||||
<div class="panel-body" style="flex:1;display:flex;flex-direction:column;padding:0">
|
||||
<div id="chat-messages" style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px"></div>
|
||||
<div style="padding:16px;border-top:1px solid var(--border);background:#0a0e15">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input id="chat-input" type="text" placeholder="问我任何关于投资的问题,例如:帮我找近期突破的科技股"
|
||||
style="flex:1;height:38px;background:#0f1419;border:1px solid var(--border);color:var(--text);padding:0 12px;border-radius:4px"/>
|
||||
<button id="chat-send" class="btn-run" style="height:38px;padding:0 24px">发送</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;color:var(--text-dim);font-size:11px">
|
||||
💡 快速问题:
|
||||
<span class="chat-quick" style="color:var(--accent);cursor:pointer;margin-left:8px">帮我选股</span>
|
||||
<span class="chat-quick" style="color:var(--accent);cursor:pointer;margin-left:8px">我的持仓怎么样</span>
|
||||
<span class="chat-quick" style="color:var(--accent);cursor:pointer;margin-left:8px">当前市场适合什么策略</span>
|
||||
<span class="chat-quick" style="color:var(--accent);cursor:pointer;margin-left:8px">分析贵州茅台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const messagesEl = document.getElementById('chat-messages');
|
||||
const inputEl = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('chat-send');
|
||||
|
||||
// 渲染消息
|
||||
const renderMessage = (role, content, type = 'text', data = null) => {
|
||||
const isUser = role === 'user';
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.style.cssText = `display:flex;gap:12px;${isUser ? 'flex-direction:row-reverse' : ''}`;
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.style.cssText = 'width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0';
|
||||
avatar.style.background = isUser ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)';
|
||||
avatar.textContent = isUser ? '👤' : '🤖';
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.cssText = `flex:1;padding:12px 16px;border-radius:8px;line-height:1.8;white-space:pre-wrap;${isUser ? 'background:#2a3140;text-align:right' : 'background:#0f1419'}`;
|
||||
contentDiv.textContent = content;
|
||||
|
||||
msgDiv.appendChild(avatar);
|
||||
msgDiv.appendChild(contentDiv);
|
||||
messagesEl.appendChild(msgDiv);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async (message) => {
|
||||
if (!message.trim()) return;
|
||||
|
||||
// 显示用户消息
|
||||
renderMessage('user', message);
|
||||
_CHAT_MESSAGES.push({ role: 'user', content: message });
|
||||
|
||||
// 显示加载中
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.id = 'loading-msg';
|
||||
loadingDiv.style.cssText = 'display:flex;gap:12px';
|
||||
loadingDiv.innerHTML = `<div style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;background:linear-gradient(135deg, #f093fb 0%, #f5576c 100%)">🤖</div>
|
||||
<div style="padding:12px 16px;border-radius:8px;background:#0f1419"><div class="loading" style="padding:0">思考中...</div></div>`;
|
||||
messagesEl.appendChild(loadingDiv);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: _SESSION_ID, message })
|
||||
}).then(x => x.json());
|
||||
|
||||
// 移除加载中
|
||||
const loading = document.getElementById('loading-msg');
|
||||
if (loading) loading.remove();
|
||||
|
||||
if (r.ok) {
|
||||
renderMessage('assistant', r.text, r.type, r.data);
|
||||
_CHAT_MESSAGES.push({ role: 'assistant', content: r.text });
|
||||
} else {
|
||||
renderMessage('assistant', r.text || '抱歉,出现了错误,请稍后再试');
|
||||
}
|
||||
} catch (e) {
|
||||
const loading = document.getElementById('loading-msg');
|
||||
if (loading) loading.remove();
|
||||
renderMessage('assistant', '连接失败,请检查后端是否正常运行');
|
||||
}
|
||||
|
||||
inputEl.value = '';
|
||||
};
|
||||
|
||||
// 事件绑定
|
||||
sendBtn.onclick = () => sendMessage(inputEl.value);
|
||||
inputEl.onkeypress = (e) => {
|
||||
if (e.key === 'Enter') sendMessage(inputEl.value);
|
||||
};
|
||||
|
||||
document.querySelectorAll('.chat-quick').forEach(el => {
|
||||
el.onclick = () => sendMessage(el.textContent);
|
||||
});
|
||||
|
||||
document.getElementById('chat-clear').onclick = async () => {
|
||||
if (!confirm('确定要清空对话记录吗?')) return;
|
||||
try {
|
||||
await fetch(API_BASE + '/api/chat/' + _SESSION_ID, { method: 'DELETE' });
|
||||
_SESSION_ID = 'session_' + Date.now();
|
||||
_CHAT_MESSAGES = [];
|
||||
messagesEl.innerHTML = '';
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// 欢迎消息
|
||||
renderMessage('assistant', '👋 你好!我是Blackdata AI助手。\n\n我可以帮你:\n• 自然语言选股(\"帮我找近期突破的科技股\")\n• 诊断持仓风险(\"我的持仓有什么问题\")\n• 给出策略建议(\"当前市场怎么操作\")\n• 分析个股和板块(\"分析一下贵州茅台\")\n\n有什么可以帮你的吗?');
|
||||
};
|
||||
203
prototype/app.js
vendored
203
prototype/app.js
vendored
@@ -50,7 +50,7 @@ const MENU = [
|
||||
{ id: 'hot-stock', name: '热门股票' },
|
||||
{ id: 'hot-sector', name: '热门板块' },
|
||||
{ id: 'dragon', name: '龙虎榜' },
|
||||
{ id: 'radar', name: '异动雷达', soon: true },
|
||||
{ id: 'radar', name: '异动雷达' },
|
||||
]},
|
||||
{ icon: '⌖', name: '选股引擎', children: [
|
||||
{ id: 'screen-strat', name: '策略选股' },
|
||||
@@ -385,6 +385,71 @@ const VIEWS = {
|
||||
<div class="panel-body" style="padding:0">${rankTable(['名称/代码','涨跌幅','龙虎榜净买额','上榜原因'],rows)}</div></div>`;
|
||||
},
|
||||
|
||||
async radar(view) {
|
||||
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>盘中异动雷达 <span class="sub" id="radar-status">检测中…</span>
|
||||
<button id="radar-scan" class="btn-run" style="margin-left:auto">立即扫描</button>
|
||||
<button id="radar-notify" class="btn-run" style="background:#2a3140;border-color:#2a3140">推送异动</button></div>
|
||||
<div class="panel-body"><div id="radar-stats" class="row c4" style="margin-bottom:8px"></div><div id="radar-events"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>今日异动统计</div>
|
||||
<div class="panel-body"><div id="radar-chart" style="height:280px"></div></div></div>`;
|
||||
|
||||
const loadEvents = async () => {
|
||||
let r; try { r = await apiGet('/api/radar/events?hours=2&limit=50'); } catch { document.getElementById('radar-events').innerHTML='<div class="loading">后端未连接</div>'; return; }
|
||||
const typeNames = {surge:'快速拉升',volume_break:'放量突破',limit_open:'涨停打开',consecutive:'连板追踪',big_order:'大单异动'};
|
||||
if (!r.list.length) { document.getElementById('radar-events').innerHTML='<div class="loading">最近2小时暂无异动</div>'; return; }
|
||||
const rows = r.list.map(e=>[`<td>${e.detected_at}</td>`,
|
||||
`<td><b>${e.name}</b> <span style="color:var(--text-mute)">${e.code}</span></td>`,
|
||||
`<td><span class="tag hot">${typeNames[e.event_type]||e.event_type}</span></td>`,
|
||||
`<td class="num">${fmt(e.price)}</td>`,
|
||||
`<td class="${cls(e.pct)} num">${sign(e.pct)}${fmt(e.pct)}%</td>`,
|
||||
`<td class="num">${e.volume_ratio?fmt(e.volume_ratio,2):'-'}</td>`,
|
||||
`<td style="text-align:left;color:var(--text-dim)">${e.description}</td>`]);
|
||||
document.getElementById('radar-events').innerHTML = rankTable(['时间','名称/代码','事件','现价','涨跌幅','量比','描述'], rows);
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
let st; try { st = await apiGet('/api/radar/stats'); } catch { return; }
|
||||
const typeNames = {surge:'快速拉升',volume_break:'放量突破',limit_open:'涨停打开',consecutive:'连板',big_order:'大单'};
|
||||
const data = Object.entries(st.by_type||{}).map(([k,v])=>({name:typeNames[k]||k,value:v}));
|
||||
if (data.length) {
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('radar-chart'));
|
||||
c.setOption({backgroundColor:'transparent',tooltip:{trigger:'item',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}},
|
||||
legend:{orient:'vertical',left:'left',textStyle:{color:AXIS}},
|
||||
series:[{type:'pie',radius:'65%',data:data.map(d=>({name:d.name,value:d.value})),
|
||||
label:{color:AXIS},itemStyle:{borderRadius:4,borderColor:'#0a0e15',borderWidth:2}}]});
|
||||
}
|
||||
const card=(l,v)=>`<div class="idx-card"><div class="nm">${l}</div><div class="pr num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('radar-stats').innerHTML = card('今日异动',st.total||0) +
|
||||
(st.top_stocks||[]).slice(0,3).map(s=>card(s.name,s.count+'次')).join('');
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
try { const s = await apiGet('/api/radar/status');
|
||||
document.getElementById('radar-status').innerHTML = s.trading_time ? '<span style="color:var(--up)">● 交易时间 · 自动监控中</span>' : '<span style="color:var(--text-mute)">○ 非交易时间</span>';
|
||||
} catch {}
|
||||
};
|
||||
|
||||
document.getElementById('radar-scan').onclick = async () => {
|
||||
document.getElementById('radar-status').textContent = '扫描中…';
|
||||
try { const r = await apiPost('/api/radar/scan');
|
||||
document.getElementById('radar-status').innerHTML = r.ok ? `扫描完成,发现 ${r.count} 条异动` : r.msg||'扫描失败';
|
||||
await loadEvents(); await loadStats();
|
||||
} catch { document.getElementById('radar-status').textContent = '扫描失败'; }
|
||||
};
|
||||
|
||||
document.getElementById('radar-notify').onclick = async () => {
|
||||
try { const r = await apiPost('/api/radar/notify');
|
||||
alert(r.ok ? (r.msg||`已推送 ${r.count} 条`) : (r.msg||'推送失败'));
|
||||
await loadEvents();
|
||||
} catch { alert('推送失败'); }
|
||||
};
|
||||
|
||||
await checkStatus();
|
||||
await loadEvents();
|
||||
await loadStats();
|
||||
},
|
||||
|
||||
async 'watch-list'(view) {
|
||||
let list; try { list = (await apiGet('/api/watchlist')).list; } catch { list = []; }
|
||||
const rows = list.map(r=>[`<td><b>${r.name}</b> <span style="color:var(--text-mute)">${r.code}</span></td>`,
|
||||
@@ -784,25 +849,124 @@ const VIEWS = {
|
||||
},
|
||||
|
||||
async 'pf-attr'(view) {
|
||||
let p; try { p = await apiGet('/api/portfolio'); } catch { view.innerHTML='<div class="panel"><div class="placeholder"><h2>盈亏归因</h2><p>后端未连接</p></div></div>'; return; }
|
||||
const a = p.attribution;
|
||||
if (!a.by_stock.length) { view.innerHTML='<div class="panel"><div class="placeholder"><h2>盈亏归因</h2><p>暂无交易数据,请先在「交易日志」录入。</p></div></div>'; return; }
|
||||
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>个股盈亏贡献 <span class="sub">已实现+浮动</span></div>
|
||||
<div class="panel-body"><div id="attr-stock" style="height:340px"></div></div></div>
|
||||
<div class="row c2">
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>按交易理由归因</div><div class="panel-body" style="padding:0"><div id="attr-reason"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>按交易情绪归因</div><div class="panel-body" style="padding:0"><div id="attr-emotion"></div></div></div>
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>持仓归因分析(深化版) <span class="sub">多维度拆解盈亏来源</span>
|
||||
<button id="attr-load" class="btn-run" style="margin-left:auto">刷新分析</button></div>
|
||||
<div class="panel-body"><div id="attr-container"><div class="loading">加载中…</div></div></div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
document.getElementById('attr-container').innerHTML = '<div class="loading">分析中…</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/portfolio/attribution');
|
||||
} catch {
|
||||
document.getElementById('attr-container').innerHTML = '后端未连接';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('attr-container').innerHTML = `<div class="placeholder"><h2>持仓归因分析</h2><p>${r.msg},请先在「交易日志」录入交易。</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// 1. 选股 vs 择时能力
|
||||
if (r.stock_vs_timing && r.stock_vs_timing.ok) {
|
||||
const st = r.stock_vs_timing;
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
html += `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>选股 vs 择时能力</div>
|
||||
<div class="panel-body">
|
||||
<div class="row c2" style="margin-bottom:12px">
|
||||
${card('选股能力', sign(st.stock_ability) + st.stock_ability + '%', cls(st.stock_ability))}
|
||||
${card('择时能力', st.timing_ability + '分', st.timing_ability >= 60 ? 'up' : (st.timing_ability < 40 ? 'down' : ''))}
|
||||
</div>
|
||||
<div style="padding:12px;background:#0f1419;border-radius:4px;line-height:1.8">
|
||||
<div><b>选股能力</b>:${st.stock_ability >= 0 ? '选对了股票(股票整体上涨)' : '选错了股票(股票整体下跌)'}</div>
|
||||
<div><b>择时能力</b>:${st.timing_ability >= 60 ? '买卖点把握较好' : (st.timing_ability < 40 ? '买卖点偏差较大' : '买卖点一般')}(满分100,50分为平均水平)</div>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
// 2. 持仓时长分析
|
||||
if (r.hold_period && r.hold_period.ok) {
|
||||
const hp = r.hold_period;
|
||||
const periodCard = (name, stats) => {
|
||||
if (stats.count === 0) return '';
|
||||
return `<div class="panel"><div class="panel-head"><span class="bar"></span>${name}</div>
|
||||
<div class="panel-body">
|
||||
<div style="display:flex;gap:12px;margin-bottom:8px">
|
||||
<div style="flex:1"><div style="color:var(--text-dim);font-size:12px">交易次数</div><div class="num" style="font-size:18px;font-weight:600">${stats.count}</div></div>
|
||||
<div style="flex:1"><div style="color:var(--text-dim);font-size:12px">胜率</div><div class="num ${stats.win_rate >= 60 ? 'up' : (stats.win_rate < 40 ? 'down' : '')}" style="font-size:18px;font-weight:600">${stats.win_rate}%</div></div>
|
||||
<div style="flex:1"><div style="color:var(--text-dim);font-size:12px">平均收益</div><div class="num ${cls(stats.avg_return)}" style="font-size:18px;font-weight:600">${sign(stats.avg_return)}${stats.avg_return}%</div></div>
|
||||
</div>
|
||||
<div style="color:var(--text-dim);font-size:12px">平均持仓:${stats.avg_days}天 总盈亏:<span class="${cls(stats.total_pnl)}">${sign(stats.total_pnl)}${fmt(stats.total_pnl)}</span></div>
|
||||
</div></div>`;
|
||||
};
|
||||
html += `<div class="row c3" style="margin-bottom:10px">
|
||||
${periodCard('短线(≤5天)', hp.short_term)}
|
||||
${periodCard('中线(6-30天)', hp.mid_term)}
|
||||
${periodCard('长线(>30天)', hp.long_term)}
|
||||
</div>`;
|
||||
const c = newChart(document.getElementById('attr-stock'));
|
||||
const ds = a.by_stock.slice(0,15);
|
||||
c.setOption({ backgroundColor:'transparent', grid:{left:90,right:30,top:10,bottom:20},
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'shadow'},backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}},
|
||||
xAxis:{type:'value',axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}},
|
||||
yAxis:{type:'category',data:ds.map(x=>x.name).reverse(),axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}},
|
||||
series:[{type:'bar',data:ds.map(x=>({value:x.pnl,itemStyle:{color:x.pnl>=0?UP:DOWN}})).reverse(),barWidth:'60%',label:{show:true,position:'right',color:AXIS,formatter:p=>fmt(p.value)}}] });
|
||||
const tagTable = (arr) => `<table class="grid-tbl"><tbody>${arr.map(x=>`<tr><td>${x.key}</td><td class="${cls(x.pnl)} num">${sign(x.pnl)}${fmt(x.pnl)}</td></tr>`).join('')}</tbody></table>`;
|
||||
document.getElementById('attr-reason').innerHTML = tagTable(a.by_reason);
|
||||
document.getElementById('attr-emotion').innerHTML = tagTable(a.by_emotion);
|
||||
html += `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>持仓周期建议</div>
|
||||
<div class="panel-body" style="padding:12px;background:#0f1419;border-radius:4px">${hp.recommendation}</div></div>`;
|
||||
}
|
||||
|
||||
// 3. 买入理由有效性
|
||||
if (r.reason_validity && r.reason_validity.ok) {
|
||||
const rv = r.reason_validity;
|
||||
const rows = rv.by_reason.map(r => [
|
||||
`<td>${r.reason}</td>`,
|
||||
`<td class="num">${r.count}</td>`,
|
||||
`<td class="num ${r.win_rate >= 50 ? 'up' : 'down'}">${r.win_rate}%</td>`,
|
||||
`<td class="${cls(r.avg_return)} num">${sign(r.avg_return)}${fmt(r.avg_return)}%</td>`,
|
||||
`<td class="${cls(r.total_pnl)} num">${sign(r.total_pnl)}${fmt(r.total_pnl)}</td>`,
|
||||
`<td><span class="tag ${r.effectiveness === '有效' ? '' : 'hot'}">${r.effectiveness}</span></td>`
|
||||
]);
|
||||
html += `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>买入理由有效性验证</div>
|
||||
<div class="panel-body" style="padding:0">${rankTable(['理由', '次数', '胜率', '平均收益', '总盈亏', '有效性'], rows)}</div></div>`;
|
||||
}
|
||||
|
||||
// 4. 情绪标签相关性
|
||||
if (r.emotion_correlation && r.emotion_correlation.ok) {
|
||||
const ec = r.emotion_correlation;
|
||||
const rows = ec.by_emotion.map(e => [
|
||||
`<td>${e.emotion}</td>`,
|
||||
`<td class="num">${e.count}</td>`,
|
||||
`<td class="num ${e.win_rate >= 50 ? 'up' : 'down'}">${e.win_rate}%</td>`,
|
||||
`<td class="${cls(e.avg_return)} num">${sign(e.avg_return)}${fmt(e.avg_return)}%</td>`,
|
||||
`<td class="${cls(e.total_pnl)} num">${sign(e.total_pnl)}${fmt(e.total_pnl)}</td>`
|
||||
]);
|
||||
html += `<div class="row" style="margin-bottom:10px">
|
||||
<div class="panel" style="flex:2"><div class="panel-head"><span class="bar"></span>情绪标签相关性</div>
|
||||
<div class="panel-body" style="padding:0">${rankTable(['情绪', '次数', '胜率', '平均收益', '总盈亏'], rows)}</div></div>
|
||||
<div class="panel" style="flex:1"><div class="panel-head"><span class="bar"></span>情绪建议</div>
|
||||
<div class="panel-body" style="padding:12px;line-height:1.8;white-space:pre-wrap">${ec.advice}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 5. 超额收益拆解
|
||||
if (r.excess_return && r.excess_return.ok) {
|
||||
const er = r.excess_return;
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
html += `<div class="panel"><div class="panel-head"><span class="bar"></span>对标沪深300超额收益 <span class="sub">${er.period}</span></div>
|
||||
<div class="panel-body">
|
||||
<div class="row c3" style="margin-bottom:12px">
|
||||
${card('组合收益', sign(er.portfolio_return) + er.portfolio_return + '%', cls(er.portfolio_return))}
|
||||
${card('沪深300', sign(er.index_return) + er.index_return + '%', cls(er.index_return))}
|
||||
${card('超额收益', sign(er.excess_return) + er.excess_return + '%', cls(er.excess_return))}
|
||||
</div>
|
||||
<div style="padding:12px;background:#0f1419;border-radius:4px;line-height:1.8">
|
||||
<div><b>来源</b>:${er.source}</div>
|
||||
<div>${er.interpretation}</div>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
document.getElementById('attr-container').innerHTML = html;
|
||||
};
|
||||
|
||||
document.getElementById('attr-load').onclick = load;
|
||||
load();
|
||||
},
|
||||
|
||||
async 'pf-equity'(view) {
|
||||
@@ -969,7 +1133,6 @@ function mdToHtml(md) {
|
||||
|
||||
/* ===================== 占位(建设中) ===================== */
|
||||
const SOON_FEATURES = {
|
||||
'radar': ['快速拉升监测','放量突破','涨停打开提醒','大单扫货追踪'],
|
||||
'screen-cond': ['技术+资金+基本面多因子拖拽','结果一键回测','一键存为预警'],
|
||||
'screen-sector': ['按板块/概念/产业链筛选','板块强度排序'],
|
||||
'screen-strat': ['内置策略库','MACD金叉/底部放量/筹码集中'],
|
||||
|
||||
208
prototype/backtest-enhanced.js
vendored
Normal file
208
prototype/backtest-enhanced.js
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
// 增强回测功能扩展
|
||||
|
||||
// 替换原有的 backtest 视图函数
|
||||
VIEWS.backtest = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>策略回测(增强版) <span class="sub">多因子 · 仓位管理 · 止损止盈</span>
|
||||
<span class="seg" id="bt-mode"><button data-m="simple" class="active">快速回测</button><button data-m="advanced">高级回测</button><button data-m="optimize">参数优化</button><button data-m="compare">策略对比</button></span></div>
|
||||
<div class="panel-body"><div id="bt-container"></div></div></div>`;
|
||||
|
||||
const renderSimple = () => {
|
||||
document.getElementById('bt-container').innerHTML = `<div class="bt-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:10px">
|
||||
<label class="cond">代码<input id="bt-sym" value="600519" style="width:90px"/></label>
|
||||
<label class="cond">快线<input id="bt-fast" value="5" type="number" style="width:60px"/></label>
|
||||
<label class="cond">慢线<input id="bt-slow" value="20" type="number" style="width:60px"/></label>
|
||||
<button id="bt-run" class="btn-run">回测</button>
|
||||
</div>
|
||||
<div id="bt-metrics" class="row c4" style="margin-bottom:8px"></div>
|
||||
<div id="bt-chart" style="height:420px"></div>
|
||||
<div id="bt-msg" style="color:var(--text-dim);padding:8px"></div>`;
|
||||
document.getElementById('bt-run').onclick = runSimple;
|
||||
};
|
||||
|
||||
const renderAdvanced = () => {
|
||||
document.getElementById('bt-container').innerHTML = `<div class="bt-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:10px">
|
||||
<label class="cond">代码<input id="bta-sym" value="600519" style="width:90px"/></label>
|
||||
<label class="cond">策略<select id="bta-strat" style="height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text)"><option value="ma">均线交叉</option><option value="multi_factor">多因子</option></select></label>
|
||||
<label class="cond">快线<input id="bta-fast" value="5" type="number" style="width:60px"/></label>
|
||||
<label class="cond">慢线<input id="bta-slow" value="20" type="number" style="width:60px"/></label>
|
||||
<label class="cond">仓位<input id="bta-size" value="1" type="number" step="0.1" min="0" max="1" style="width:60px"/></label>
|
||||
<label class="cond">止损%<input id="bta-stop" value="0" type="number" step="0.5" style="width:60px"/></label>
|
||||
<label class="cond">止盈%<input id="bta-profit" value="0" type="number" step="0.5" style="width:60px"/></label>
|
||||
<button id="bta-run" class="btn-run">高级回测</button>
|
||||
</div>
|
||||
<div id="bta-metrics" class="row c4" style="margin-bottom:8px"></div>
|
||||
<div id="bta-chart" style="height:420px"></div>
|
||||
<div style="margin:12px 0;display:flex;gap:8px;align-items:center"><b>交易明细</b><button id="bta-export" class="btn-run" style="background:#2a3140;border-color:#2a3140">导出CSV</button></div>
|
||||
<div id="bta-trades"></div>`;
|
||||
document.getElementById('bta-run').onclick = runAdvanced;
|
||||
document.getElementById('bta-export').onclick = exportTrades;
|
||||
};
|
||||
|
||||
const renderOptimize = () => {
|
||||
document.getElementById('bt-container').innerHTML = `<div class="bt-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:10px">
|
||||
<label class="cond">代码<input id="opt-sym" value="600519" style="width:90px"/></label>
|
||||
<label class="cond">快线范围<input id="opt-fast" value="3,5,10,15" style="width:100px" placeholder="逗号分隔"/></label>
|
||||
<label class="cond">慢线范围<input id="opt-slow" value="20,30,60" style="width:100px" placeholder="逗号分隔"/></label>
|
||||
<label class="cond">优化目标<select id="opt-metric" style="height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text)"><option value="sharpe_ratio">夏普比率</option><option value="total_return">总收益</option><option value="calmar_ratio">卡玛比率</option></select></label>
|
||||
<button id="opt-run" class="btn-run">开始优化</button>
|
||||
</div>
|
||||
<div id="opt-msg" style="color:var(--text-dim);margin-bottom:8px"></div>
|
||||
<div id="opt-results"></div>`;
|
||||
document.getElementById('opt-run').onclick = runOptimize;
|
||||
};
|
||||
|
||||
const renderCompare = () => {
|
||||
document.getElementById('bt-container').innerHTML = `<div class="bt-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:10px">
|
||||
<label class="cond">代码<input id="cmp-sym" value="600519" style="width:90px"/></label>
|
||||
<button id="cmp-run" class="btn-run">对比策略</button>
|
||||
</div>
|
||||
<div id="cmp-msg" style="color:var(--text-dim);margin-bottom:8px"></div>
|
||||
<div id="cmp-metrics" class="row c4" style="margin-bottom:8px"></div>
|
||||
<div id="cmp-chart" style="height:420px"></div>`;
|
||||
document.getElementById('cmp-run').onclick = runCompare;
|
||||
};
|
||||
|
||||
let _TRADES_DATA = [];
|
||||
|
||||
const runSimple = async () => {
|
||||
const sym = document.getElementById('bt-sym').value.trim();
|
||||
const fast = document.getElementById('bt-fast').value;
|
||||
const slow = document.getElementById('bt-slow').value;
|
||||
document.getElementById('bt-msg').textContent = '回测中…';
|
||||
let r; try { r = await apiGet(`/api/backtest?symbol=${sym}&fast=${fast}&slow=${slow}`); } catch { document.getElementById('bt-msg').textContent='后端未连接'; return; }
|
||||
if (!r.ok) { document.getElementById('bt-msg').textContent = r.msg || '回测失败'; document.getElementById('bt-metrics').innerHTML=''; return; }
|
||||
document.getElementById('bt-msg').textContent = '';
|
||||
const m = r.metrics;
|
||||
const card = (l,v,c)=>`<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c||''} num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('bt-metrics').innerHTML =
|
||||
card('策略收益', sign(m.total_return)+m.total_return+'%', cls(m.total_return))
|
||||
+ card('基准(持有)', sign(m.bench_return)+m.bench_return+'%', cls(m.bench_return))
|
||||
+ card('超额收益', sign(m.excess)+m.excess+'%', cls(m.excess))
|
||||
+ card('最大回撤', '-'+m.max_drawdown+'%', 'down')
|
||||
+ card('交易次数', m.trades) + card('胜率', m.win_rate+'%');
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('bt-chart'));
|
||||
c.setOption({ backgroundColor:'transparent', tooltip:{trigger:'axis',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}},
|
||||
legend:{data:['策略净值','买入持有'],textStyle:{color:AXIS},top:0}, grid:{left:50,right:20,top:30,bottom:40},
|
||||
xAxis:{type:'category',data:r.dates,axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}},
|
||||
yAxis:{type:'value',scale:true,axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}},
|
||||
dataZoom:[{type:'inside'},{type:'slider',bottom:5,height:14,textStyle:{color:AXIS},borderColor:GRID}],
|
||||
series:[{name:'策略净值',type:'line',data:r.equity,symbol:'none',lineStyle:{width:1.6,color:'#e8a13a'}},
|
||||
{name:'买入持有',type:'line',data:r.bench,symbol:'none',lineStyle:{width:1.2,color:'#2f6fed'}}] });
|
||||
};
|
||||
|
||||
const runAdvanced = async () => {
|
||||
const body = {
|
||||
symbol: document.getElementById('bta-sym').value.trim(),
|
||||
strategy: document.getElementById('bta-strat').value,
|
||||
fast: +document.getElementById('bta-fast').value,
|
||||
slow: +document.getElementById('bta-slow').value,
|
||||
position_size: +document.getElementById('bta-size').value,
|
||||
stop_loss: +document.getElementById('bta-stop').value,
|
||||
take_profit: +document.getElementById('bta-profit').value
|
||||
};
|
||||
document.getElementById('bta-trades').innerHTML = '<div class="loading">回测中…</div>';
|
||||
let r; try { r = await fetch(API_BASE+'/api/backtest/advanced',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json()); } catch { document.getElementById('bta-trades').innerHTML='后端未连接'; return; }
|
||||
if (!r.ok) { document.getElementById('bta-trades').innerHTML = r.msg||'回测失败'; return; }
|
||||
_TRADES_DATA = r.trades;
|
||||
const m = r.metrics;
|
||||
const card = (l,v,c)=>`<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c||''} num" style="font-size:18px">${v}</div></div>`;
|
||||
document.getElementById('bta-metrics').innerHTML =
|
||||
card('总收益', sign(m.total_return)+m.total_return+'%', cls(m.total_return))
|
||||
+ card('最大回撤', '-'+m.max_drawdown+'%', 'down')
|
||||
+ card('夏普比率', m.sharpe_ratio)
|
||||
+ card('卡玛比率', m.calmar_ratio)
|
||||
+ card('交易次数', m.trades) + card('胜率', m.win_rate+'%')
|
||||
+ card('盈亏比', m.profit_factor) + card('平均持仓', m.avg_hold_days+'天');
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('bta-chart'));
|
||||
c.setOption({ backgroundColor:'transparent', tooltip:{trigger:'axis',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}},
|
||||
legend:{data:['策略净值','买入持有'],textStyle:{color:AXIS},top:0}, grid:{left:60,right:20,top:30,bottom:40},
|
||||
xAxis:{type:'category',data:r.dates,axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}},
|
||||
yAxis:{type:'value',scale:true,axisLabel:{color:AXIS,formatter:v=>fmt(v,0)},splitLine:{lineStyle:{color:GRID}}},
|
||||
dataZoom:[{type:'inside'},{type:'slider',bottom:5,height:14,textStyle:{color:AXIS},borderColor:GRID}],
|
||||
series:[{name:'策略净值',type:'line',data:r.equity,symbol:'none',lineStyle:{width:1.6,color:'#e8a13a'}},
|
||||
{name:'买入持有',type:'line',data:r.bench,symbol:'none',lineStyle:{width:1.2,color:'#2f6fed'}}] });
|
||||
const rows = r.trades.map(t=>[`<td>${t.entry_date}</td>`,`<td>${t.exit_date||'持仓中'}</td>`,
|
||||
`<td class="num">${fmt(t.entry_price)}</td>`,`<td class="num">${t.exit_price?fmt(t.exit_price):'-'}</td>`,
|
||||
`<td class="num">${fmt(t.shares,0)}</td>`,`<td class="num">${t.hold_days}</td>`,
|
||||
`<td class="${cls(t.pnl)} num">${sign(t.pnl)}${fmt(t.pnl)}</td>`,
|
||||
`<td class="${cls(t.pnl_pct)} num">${sign(t.pnl_pct)}${fmt(t.pnl_pct)}%</td>`,
|
||||
`<td>${t.reason}</td>`]);
|
||||
document.getElementById('bta-trades').innerHTML = rows.length?rankTable(['买入日期','卖出日期','买入价','卖出价','股数','持仓天数','盈亏','收益率','理由'],rows):'<div class="loading">暂无交易</div>';
|
||||
};
|
||||
|
||||
const exportTrades = () => {
|
||||
if (!_TRADES_DATA.length) { alert('暂无交易数据'); return; }
|
||||
const csv = 'entry_date,exit_date,entry_price,exit_price,shares,hold_days,pnl,pnl_pct,reason\n'
|
||||
+ _TRADES_DATA.map(t=>`${t.entry_date},${t.exit_date},${t.entry_price},${t.exit_price},${t.shares},${t.hold_days},${t.pnl},${t.pnl_pct},"${t.reason}"`).join('\n');
|
||||
const blob = new Blob([csv], {type:'text/csv'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'trades.csv'; a.click();
|
||||
};
|
||||
|
||||
const runOptimize = async () => {
|
||||
const body = {
|
||||
symbol: document.getElementById('opt-sym').value.trim(),
|
||||
fast_range: document.getElementById('opt-fast').value.split(',').map(x=>+x.trim()),
|
||||
slow_range: document.getElementById('opt-slow').value.split(',').map(x=>+x.trim()),
|
||||
metric: document.getElementById('opt-metric').value
|
||||
};
|
||||
document.getElementById('opt-msg').textContent = '优化中,请稍候…';
|
||||
document.getElementById('opt-results').innerHTML = '';
|
||||
let r; try { r = await fetch(API_BASE+'/api/backtest/optimize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json()); } catch { document.getElementById('opt-msg').textContent='后端未连接'; return; }
|
||||
if (!r.ok) { document.getElementById('opt-msg').textContent=r.msg||'优化失败'; return; }
|
||||
document.getElementById('opt-msg').textContent = `完成,测试了 ${r.results.length} 组参数`;
|
||||
const rows = r.results.map((x,i)=>[`<td>${i+1}</td>`,
|
||||
`<td>快线${x.params.fast} / 慢线${x.params.slow}</td>`,
|
||||
`<td class="num">${fmt(x[r.metric],3)}</td>`,
|
||||
`<td class="${cls(x.metrics.total_return)} num">${sign(x.metrics.total_return)}${fmt(x.metrics.total_return)}%</td>`,
|
||||
`<td class="num">${fmt(x.metrics.sharpe_ratio,3)}</td>`,
|
||||
`<td class="num">${fmt(x.metrics.max_drawdown,2)}%</td>`,
|
||||
`<td class="num">${x.metrics.win_rate}%</td>`]);
|
||||
document.getElementById('opt-results').innerHTML = rankTable(['排名','参数',r.metric,'总收益','夏普','最大回撤','胜率'],rows);
|
||||
};
|
||||
|
||||
const runCompare = async () => {
|
||||
const sym = document.getElementById('cmp-sym').value.trim();
|
||||
const body = {
|
||||
symbol: sym,
|
||||
strategies: [
|
||||
{type:'ma',fast:5,slow:20},
|
||||
{type:'ma',fast:5,slow:20,stop_loss:5,take_profit:10},
|
||||
{type:'ma',fast:10,slow:30},
|
||||
{type:'multi_factor'}
|
||||
]
|
||||
};
|
||||
document.getElementById('cmp-msg').textContent = '对比中…';
|
||||
let r; try { r = await fetch(API_BASE+'/api/backtest/compare',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json()); } catch { document.getElementById('cmp-msg').textContent='后端未连接'; return; }
|
||||
if (!r.ok) { document.getElementById('cmp-msg').textContent=r.msg||'对比失败'; return; }
|
||||
document.getElementById('cmp-msg').textContent = `对比 ${r.strategies.length} 个策略`;
|
||||
const card = (l,v,c)=>`<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c||''} num" style="font-size:16px">${v}</div></div>`;
|
||||
document.getElementById('cmp-metrics').innerHTML = r.strategies.map(s=>
|
||||
card(s.strategy, sign(s.metrics.total_return)+s.metrics.total_return+'%', cls(s.metrics.total_return))
|
||||
).join('');
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('cmp-chart'));
|
||||
const series = r.strategies.map((s,i)=>({name:s.strategy,type:'line',data:s.equity,symbol:'none',lineStyle:{width:1.4,color:['#e8a13a','#2f6fed','#a855f7','#06b6d4'][i%4]}}));
|
||||
c.setOption({ backgroundColor:'transparent', tooltip:{trigger:'axis',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}},
|
||||
legend:{data:r.strategies.map(s=>s.strategy),textStyle:{color:AXIS},top:0}, grid:{left:60,right:20,top:30,bottom:40},
|
||||
xAxis:{type:'category',data:r.dates,axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}},
|
||||
yAxis:{type:'value',scale:true,axisLabel:{color:AXIS,formatter:v=>fmt(v,0)},splitLine:{lineStyle:{color:GRID}}},
|
||||
dataZoom:[{type:'inside'},{type:'slider',bottom:5,height:14,textStyle:{color:AXIS},borderColor:GRID}], series });
|
||||
};
|
||||
|
||||
document.getElementById('bt-mode').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b=>b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
const mode = e.target.dataset.m;
|
||||
if (mode === 'simple') renderSimple();
|
||||
else if (mode === 'advanced') renderAdvanced();
|
||||
else if (mode === 'optimize') renderOptimize();
|
||||
else if (mode === 'compare') renderCompare();
|
||||
};
|
||||
|
||||
renderSimple();
|
||||
};
|
||||
315
prototype/event-driven.js
vendored
Normal file
315
prototype/event-driven.js
vendored
Normal file
@@ -0,0 +1,315 @@
|
||||
// 事件驱动策略
|
||||
|
||||
// 添加到菜单
|
||||
if (!MENU.find(g => g.name === '事件驱动')) {
|
||||
MENU.push({
|
||||
icon: '⚡',
|
||||
name: '事件驱动',
|
||||
children: [
|
||||
{ id: 'event-earnings', name: '财报规律' },
|
||||
{ id: 'event-insider', name: '增减持' },
|
||||
{ id: 'event-unlock', name: '限售解禁' },
|
||||
{ id: 'event-policy', name: '政策事件' },
|
||||
{ id: 'event-selector', name: '事件选股' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 财报发布前后规律
|
||||
VIEWS['event-earnings'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>财报发布前后统计规律 <span class="sub">业绩超预期/不及预期的影响</span>
|
||||
<button id="earn-load" class="btn-run" style="margin-left:auto">刷新分析</button></div>
|
||||
<div class="panel-body">
|
||||
<div id="earn-summary" class="row c3" style="margin-bottom:12px"></div>
|
||||
<div id="earn-desc" style="padding:12px;background:#0f1419;border-radius:4px;margin-bottom:12px"></div>
|
||||
<div id="earn-detail"></div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
document.getElementById('earn-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">分析中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/events/earnings/pattern?days_before=5&days_after=10');
|
||||
} catch {
|
||||
document.getElementById('earn-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('earn-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = r.summary;
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:18px">${v}</div></div>`;
|
||||
|
||||
let html = '';
|
||||
if (s.positive) {
|
||||
html += `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>业绩超预期(${s.positive.count}次)</div>
|
||||
<div class="panel-body"><div class="row c4">
|
||||
${card('财报前涨幅', sign(s.positive.avg_before) + s.positive.avg_before + '%', cls(s.positive.avg_before))}
|
||||
${card('财报后涨幅', sign(s.positive.avg_after) + s.positive.avg_after + '%', cls(s.positive.avg_after))}
|
||||
${card('后续胜率', s.positive.win_rate_after + '%', s.positive.win_rate_after >= 60 ? 'up' : '')}
|
||||
${card('样本数', s.positive.count)}
|
||||
</div></div></div>`;
|
||||
}
|
||||
|
||||
if (s.negative) {
|
||||
html += `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>业绩不及预期(${s.negative.count}次)</div>
|
||||
<div class="panel-body"><div class="row c4">
|
||||
${card('财报前涨幅', sign(s.negative.avg_before) + s.negative.avg_before + '%', cls(s.negative.avg_before))}
|
||||
${card('财报后涨幅', sign(s.negative.avg_after) + s.negative.avg_after + '%', cls(s.negative.avg_after))}
|
||||
${card('后续胜率', s.negative.win_rate_after + '%')}
|
||||
${card('样本数', s.negative.count)}
|
||||
</div></div></div>`;
|
||||
}
|
||||
|
||||
if (s.neutral) {
|
||||
html += `<div class="panel"><div class="panel-head"><span class="bar"></span>业绩符合预期(${s.neutral.count}次)</div>
|
||||
<div class="panel-body"><div class="row c4">
|
||||
${card('财报前涨幅', sign(s.neutral.avg_before) + s.neutral.avg_before + '%', cls(s.neutral.avg_before))}
|
||||
${card('财报后涨幅', sign(s.neutral.avg_after) + s.neutral.avg_after + '%', cls(s.neutral.avg_after))}
|
||||
${card('后续胜率', s.neutral.win_rate_after + '%')}
|
||||
${card('样本数', s.neutral.count)}
|
||||
</div></div></div>`;
|
||||
}
|
||||
|
||||
document.getElementById('earn-summary').style.display = 'none';
|
||||
document.getElementById('earn-detail').innerHTML = html;
|
||||
|
||||
// 策略建议
|
||||
let advice = '<b>策略建议</b>:<br/>';
|
||||
if (s.positive && s.positive.avg_after > 5) {
|
||||
advice += '• 业绩超预期后,平均上涨' + s.positive.avg_after + '%,可在财报发布后介入<br/>';
|
||||
}
|
||||
if (s.negative && s.negative.avg_after < -5) {
|
||||
advice += '• 业绩不及预期后,平均下跌' + Math.abs(s.negative.avg_after) + '%,应及时止损<br/>';
|
||||
}
|
||||
advice += '<br/><b>注意</b>:以上为历史统计规律,不构成投资建议';
|
||||
document.getElementById('earn-desc').innerHTML = advice;
|
||||
};
|
||||
|
||||
document.getElementById('earn-load').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 高管增减持跟踪
|
||||
VIEWS['event-insider'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>高管增减持跟踪 <span class="sub">跟随聪明钱</span>
|
||||
<span class="seg" id="insider-seg" style="margin-left:auto"><button data-d="90">90日</button><button data-d="180" class="active">180日</button><button data-d="365">365日</button></span></div>
|
||||
<div class="panel-body">
|
||||
<div id="insider-summary" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div class="row c2">
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>高管增持(看好)</div><div class="panel-body" style="padding:0"><div id="insider-inc"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>高管减持(减持)</div><div class="panel-body" style="padding:0"><div id="insider-dec"></div></div></div>
|
||||
</div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
document.getElementById('insider-inc').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/events/insider?days=${days}`);
|
||||
} catch {
|
||||
document.getElementById('insider-inc').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('insider-inc').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('insider-summary').innerHTML =
|
||||
card('总事件数', r.total) +
|
||||
card('增持次数', r.increases.length, 'up') +
|
||||
card('减持次数', r.decreases.length, 'down') +
|
||||
card('统计周期', r.days + '天');
|
||||
|
||||
const makeTable = (list) => {
|
||||
if (!list.length) return '<div class="loading">暂无数据</div>';
|
||||
const rows = list.slice(0, 20).map(x => `<tr><td><b>${x.name}</b> <span style="color:var(--text-mute)">${x.code}</span></td>
|
||||
<td>${x.date}</td><td>${x.title}</td><td class="num">${x.amount > 0 ? fmt(x.amount, 2) + '亿' : '-'}</td></tr>`).join('');
|
||||
return `<table class="grid-tbl"><thead><tr><th>名称</th><th>日期</th><th>事件</th><th>金额</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
};
|
||||
|
||||
document.getElementById('insider-inc').innerHTML = makeTable(r.increases);
|
||||
document.getElementById('insider-dec').innerHTML = makeTable(r.decreases);
|
||||
};
|
||||
|
||||
document.getElementById('insider-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(180);
|
||||
};
|
||||
|
||||
// 限售解禁影响
|
||||
VIEWS['event-unlock'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>限售解禁影响分析 <span class="sub">解禁前后股价表现</span>
|
||||
<button id="unlock-load" class="btn-run" style="margin-left:auto">刷新分析</button></div>
|
||||
<div class="panel-body">
|
||||
<div id="unlock-summary" class="row c3" style="margin-bottom:12px"></div>
|
||||
<div id="unlock-list"></div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
document.getElementById('unlock-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">分析中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/events/unlock?days=90');
|
||||
} catch {
|
||||
document.getElementById('unlock-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('unlock-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = r.summary;
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('unlock-summary').innerHTML =
|
||||
card('解禁事件', s.total) +
|
||||
card('平均影响', sign(s.avg_impact) + s.avg_impact + '%', cls(s.avg_impact)) +
|
||||
card('下跌比例', s.negative_ratio + '%', 'down');
|
||||
|
||||
const rows = r.events.slice(0, 30).map(x => [
|
||||
`<td><b>${x.name}</b> <span style="color:var(--text-mute)">${x.code}</span></td>`,
|
||||
`<td>${x.date}</td>`,
|
||||
`<td>${x.title}</td>`,
|
||||
`<td class="num">${fmt(x.amount, 2)}亿</td>`,
|
||||
`<td class="${cls(x.impact_pct)} num">${sign(x.impact_pct)}${fmt(x.impact_pct)}%</td>`
|
||||
]);
|
||||
document.getElementById('unlock-list').innerHTML = rankTable(['名称', '日期', '事件', '解禁金额', '10日影响'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('unlock-load').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 政策事件
|
||||
VIEWS['event-policy'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>行业政策事件库 <span class="sub">政策驱动的投资机会</span>
|
||||
<button id="policy-load" class="btn-run" style="margin-left:auto">刷新</button></div>
|
||||
<div class="panel-body" style="padding:0"><div id="policy-list"></div></div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
document.getElementById('policy-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/events/policy?days=180');
|
||||
} catch {
|
||||
document.getElementById('policy-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('policy-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const typeMap = {
|
||||
subsidy: '补贴',
|
||||
support: '扶持',
|
||||
regulation: '监管',
|
||||
restriction: '限制'
|
||||
};
|
||||
|
||||
const impactColor = { positive: 'up', negative: 'down', neutral: '' };
|
||||
|
||||
const rows = r.events.map(x => [
|
||||
`<td>${x.date}</td>`,
|
||||
`<td><span class="tag">${x.sector}</span></td>`,
|
||||
`<td>${x.title}</td>`,
|
||||
`<td>${typeMap[x.policy_type] || x.policy_type}</td>`,
|
||||
`<td class="${impactColor[x.impact]}">${x.impact === 'positive' ? '利好' : (x.impact === 'negative' ? '利空' : '中性')}</td>`,
|
||||
`<td><span style="font-size:11px;color:var(--text-mute)">${x.affected_stocks.length > 0 ? x.affected_stocks.slice(0, 3).join(',') : '-'}</span></td>`
|
||||
]);
|
||||
|
||||
document.getElementById('policy-list').innerHTML = rankTable(['日期', '板块', '政策标题', '类型', '影响', '涉及股票'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('policy-load').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 事件驱动选股
|
||||
VIEWS['event-selector'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>事件驱动选股 <span class="sub">基于公司事件筛选股票</span></div>
|
||||
<div class="panel-body">
|
||||
<div style="margin-bottom:12px;padding:12px;background:#0f1419;border-radius:4px">
|
||||
<b>选择事件类型</b>(可多选):<br/>
|
||||
<label style="margin:8px 12px 0 0"><input type="checkbox" id="evt-earn-pos" checked/> 业绩超预期</label>
|
||||
<label style="margin:8px 12px 0 0"><input type="checkbox" id="evt-insider-inc" checked/> 高管增持</label>
|
||||
<label style="margin:8px 12px 0 0"><input type="checkbox" id="evt-dividend"/> 分红派息</label>
|
||||
<br/><br/>
|
||||
<b>时间范围</b>:
|
||||
<select id="evt-days" style="height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);margin:0 8px">
|
||||
<option value="7">7天</option>
|
||||
<option value="30" selected>30天</option>
|
||||
<option value="60">60天</option>
|
||||
<option value="90">90天</option>
|
||||
</select>
|
||||
<button id="evt-run" class="btn-run">开始选股</button>
|
||||
</div>
|
||||
<div id="evt-result"></div>
|
||||
</div></div>`;
|
||||
|
||||
const run = async () => {
|
||||
const eventTypes = [];
|
||||
if (document.getElementById('evt-earn-pos').checked) eventTypes.push('earnings_positive');
|
||||
if (document.getElementById('evt-insider-inc').checked) eventTypes.push('insider_increase');
|
||||
if (document.getElementById('evt-dividend').checked) eventTypes.push('dividend');
|
||||
|
||||
if (!eventTypes.length) {
|
||||
alert('请至少选择一个事件类型');
|
||||
return;
|
||||
}
|
||||
|
||||
const days = +document.getElementById('evt-days').value;
|
||||
|
||||
document.getElementById('evt-result').innerHTML = '<div class="loading">选股中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(API_BASE + '/api/events/selector', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event_types: eventTypes, days })
|
||||
}).then(x => x.json());
|
||||
} catch {
|
||||
document.getElementById('evt-result').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('evt-result').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const head = `<div style="padding:8px 12px;color:var(--text-dim);font-size:12px">命中 <b style="color:var(--text)">${r.count}</b> 只股票</div>`;
|
||||
|
||||
if (!r.count) {
|
||||
document.getElementById('evt-result').innerHTML = head + '<div class="loading">无符合条件的股票</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.stocks.slice(0, 30).map((s, i) => [
|
||||
`<td>${i + 1} <b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="num">${fmt(s.close)}</td>`,
|
||||
`<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td>`,
|
||||
`<td class="${cls(s.ret20)} num">${sign(s.ret20)}${fmt(s.ret20)}%</td>`,
|
||||
`<td class="num" style="color:var(--gold)">${s.event_score}</td>`,
|
||||
`<td style="font-size:11px">${s.events.map(e => e.title).join('<br/>')}</td>`
|
||||
]);
|
||||
|
||||
document.getElementById('evt-result').innerHTML = head + rankTable(['名称', '现价', '涨跌幅', '20日', '事件数', '事件详情'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('evt-run').onclick = run;
|
||||
};
|
||||
312
prototype/financial-analysis.js
vendored
Normal file
312
prototype/financial-analysis.js
vendored
Normal file
@@ -0,0 +1,312 @@
|
||||
// 财报深度解读
|
||||
|
||||
// 添加到菜单
|
||||
if (!MENU.find(g => g.name === '财报分析')) {
|
||||
MENU.push({
|
||||
icon: '📊',
|
||||
name: '财报分析',
|
||||
children: [
|
||||
{ id: 'fin-trend', name: '指标趋势' },
|
||||
{ id: 'fin-compare', name: '同行对比' },
|
||||
{ id: 'fin-warnings', name: '异常预警' },
|
||||
{ id: 'fin-calendar', name: '发布日历' },
|
||||
{ id: 'fin-rankings', name: '财报排行' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 财报指标趋势
|
||||
VIEWS['fin-trend'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>财报关键指标趋势 <span class="sub">ROE/毛利率/营收增速</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<input id="trend-code" value="600519" style="width:90px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<button id="trend-run" class="btn-run">分析</button>
|
||||
</span></div>
|
||||
<div class="panel-body">
|
||||
<div id="trend-summary" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div id="trend-ai" style="padding:12px;background:#0f1419;border-radius:4px;margin-bottom:12px"></div>
|
||||
<div id="trend-chart" style="height:380px"></div>
|
||||
</div></div>`;
|
||||
|
||||
const analyze = async () => {
|
||||
const code = document.getElementById('trend-code').value.trim();
|
||||
if (!code) { alert('请输入股票代码'); return; }
|
||||
|
||||
document.getElementById('trend-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">加载中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/financial/trend?code=${code}&periods=8`);
|
||||
} catch {
|
||||
document.getElementById('trend-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('trend-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 最新指标
|
||||
const l = r.latest;
|
||||
const card = (label, value, trend) => {
|
||||
const trendIcon = trend === '上升' ? '↗' : (trend === '下降' ? '↘' : '→');
|
||||
const trendColor = trend === '上升' ? 'up' : (trend === '下降' ? 'down' : '');
|
||||
return `<div class="idx-card"><div class="nm">${label}</div><div class="pr num" style="font-size:18px">${value} <span class="${trendColor}" style="font-size:14px">${trendIcon}</span></div></div>`;
|
||||
};
|
||||
|
||||
document.getElementById('trend-summary').innerHTML =
|
||||
card('ROE', l.roe + '%', r.trends.roe) +
|
||||
card('毛利率', l.gross_margin + '%', r.trends.gross_margin) +
|
||||
card('营收增速', sign(l.revenue_growth) + l.revenue_growth + '%', r.trends.revenue_growth) +
|
||||
card('利润增速', sign(l.profit_growth) + l.profit_growth + '%', r.trends.profit_growth);
|
||||
|
||||
// AI摘要
|
||||
try {
|
||||
const summary = await apiGet(`/api/financial/summary?code=${code}`);
|
||||
if (summary.ok) {
|
||||
document.getElementById('trend-ai').innerHTML = `<b>AI摘要</b>:${summary.summary}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 趋势图表
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('trend-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } },
|
||||
legend: { data: ['营收', '净利润', 'ROE', '毛利率'], textStyle: { color: AXIS }, top: 0 },
|
||||
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
||||
xAxis: { type: 'category', data: r.dates, axisLabel: { color: AXIS }, axisLine: { lineStyle: { color: GRID } } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额(亿)', axisLabel: { color: AXIS }, splitLine: { lineStyle: { color: GRID } } },
|
||||
{ type: 'value', name: '比例(%)', axisLabel: { color: AXIS }, splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{ name: '营收', type: 'bar', data: r.data.revenue, itemStyle: { color: '#2f6fed' } },
|
||||
{ name: '净利润', type: 'bar', data: r.data.net_profit, itemStyle: { color: '#2ebd85' } },
|
||||
{ name: 'ROE', type: 'line', yAxisIndex: 1, data: r.data.roe, lineStyle: { width: 2, color: '#e8a13a' } },
|
||||
{ name: '毛利率', type: 'line', yAxisIndex: 1, data: r.data.gross_margin, lineStyle: { width: 2, color: '#a855f7' } }
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('trend-run').onclick = analyze;
|
||||
analyze();
|
||||
};
|
||||
|
||||
// 同行对比
|
||||
VIEWS['fin-compare'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>同行对比 <span class="sub">与行业均值对比</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<input id="cmp-code" value="600519" style="width:90px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<button id="cmp-run" class="btn-run">对比</button>
|
||||
</span></div>
|
||||
<div class="panel-body">
|
||||
<div id="cmp-summary" class="row c3" style="margin-bottom:12px"></div>
|
||||
<div id="cmp-result"></div>
|
||||
</div></div>`;
|
||||
|
||||
const compare = async () => {
|
||||
const code = document.getElementById('cmp-code').value.trim();
|
||||
if (!code) { alert('请输入股票代码'); return; }
|
||||
|
||||
document.getElementById('cmp-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">对比中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/financial/compare?code=${code}`);
|
||||
} catch {
|
||||
document.getElementById('cmp-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('cmp-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
const conclusionColor = r.conclusion === '优于行业' ? 'up' : (r.conclusion === '弱于行业' ? 'down' : '');
|
||||
document.getElementById('cmp-summary').innerHTML =
|
||||
card('报告期', r.report_date) +
|
||||
card('优于行业指标', r.better_count + '/' + r.total_metrics) +
|
||||
card('综合结论', r.conclusion, conclusionColor);
|
||||
|
||||
// 详细对比
|
||||
const c = r.comparison;
|
||||
const metricNames = {
|
||||
roe: 'ROE',
|
||||
gross_margin: '毛利率',
|
||||
revenue_growth: '营收增速',
|
||||
profit_growth: '利润增速',
|
||||
debt_ratio: '资产负债率'
|
||||
};
|
||||
|
||||
let html = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px">';
|
||||
for (const [key, data] of Object.entries(c)) {
|
||||
const color = data.better ? 'up' : 'down';
|
||||
const icon = data.better ? '✓' : '✗';
|
||||
html += `<div class="panel"><div class="panel-head"><span class="bar"></span>${metricNames[key]}</div>
|
||||
<div class="panel-body">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<span>本公司</span><span class="${color} num" style="font-size:20px;font-weight:600">${data.value}${key.includes('ratio') || key.includes('margin') || key.includes('growth') || key === 'roe' ? '%' : ''}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<span style="color:var(--text-dim)">行业均值</span><span class="num">${data.industry_avg}${key.includes('ratio') || key.includes('margin') || key.includes('growth') || key === 'roe' ? '%' : ''}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="color:var(--text-dim)">差异</span><span class="${color} num">${sign(data.diff)}${data.diff}${key.includes('ratio') || key.includes('margin') || key.includes('growth') || key === 'roe' ? '%' : ''} ${icon}</span>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
document.getElementById('cmp-result').innerHTML = html;
|
||||
};
|
||||
|
||||
document.getElementById('cmp-run').onclick = compare;
|
||||
compare();
|
||||
};
|
||||
|
||||
// 财报异常预警
|
||||
VIEWS['fin-warnings'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>财报异常预警 <span class="sub">存货激增/应收账款/毛利率下降</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<input id="warn-code" value="600519" style="width:90px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<button id="warn-run" class="btn-run">检测</button>
|
||||
</span></div>
|
||||
<div class="panel-body">
|
||||
<div id="warn-summary" class="row c3" style="margin-bottom:12px"></div>
|
||||
<div id="warn-list"></div>
|
||||
</div></div>`;
|
||||
|
||||
const detect = async () => {
|
||||
const code = document.getElementById('warn-code').value.trim();
|
||||
if (!code) { alert('请输入股票代码'); return; }
|
||||
|
||||
document.getElementById('warn-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">检测中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/financial/warnings?code=${code}`);
|
||||
} catch {
|
||||
document.getElementById('warn-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('warn-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
const riskColor = r.risk_level === '高' ? 'down' : (r.risk_level === '中' ? '' : 'up');
|
||||
document.getElementById('warn-summary').innerHTML =
|
||||
card('报告期', r.report_date) +
|
||||
card('预警数量', r.warnings.length) +
|
||||
card('风险等级', r.risk_level, riskColor);
|
||||
|
||||
// 预警详情
|
||||
if (!r.warnings.length) {
|
||||
document.getElementById('warn-list').innerHTML = '<div class="loading">✓ 未发现异常,财报质量良好</div>';
|
||||
} else {
|
||||
let html = '<div style="display:grid;gap:12px">';
|
||||
for (const w of r.warnings) {
|
||||
const severityColor = w.severity === 'high' ? 'down' : '';
|
||||
const severityText = w.severity === 'high' ? '高风险' : '中风险';
|
||||
html += `<div style="padding:12px;background:#0f1419;border-radius:4px;border-left:3px solid ${w.severity === 'high' ? 'var(--down)' : 'var(--gold)'}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<b>${w.type}</b>
|
||||
<span class="tag ${severityColor}">${severityText}</span>
|
||||
</div>
|
||||
<div style="color:var(--text-dim)">${w.description}</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
document.getElementById('warn-list').innerHTML = html;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('warn-run').onclick = detect;
|
||||
detect();
|
||||
};
|
||||
|
||||
// 财报发布日历
|
||||
VIEWS['fin-calendar'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>财报发布日历 <span class="sub">未来30天</span>
|
||||
<button id="cal-load" class="btn-run" style="margin-left:auto">刷新</button></div>
|
||||
<div class="panel-body"><div id="cal-list"></div></div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
document.getElementById('cal-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/financial/calendar?days=30');
|
||||
} catch {
|
||||
document.getElementById('cal-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('cal-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div style="padding:8px 12px;color:var(--text-dim);font-size:12px;margin-bottom:12px">未来${r.days}天共 <b style="color:var(--text)">${r.total}</b> 只股票发布财报</div>`;
|
||||
|
||||
for (const [date, stocks] of Object.entries(r.calendar)) {
|
||||
html += `<div style="margin-bottom:12px"><div style="padding:8px 12px;background:#0f1419;border-radius:4px 4px 0 0;font-weight:600">${date}(${stocks.length}只)</div>`;
|
||||
html += '<div style="padding:12px;background:#0a0e1522;border-radius:0 0 4px 4px;display:flex;flex-wrap:wrap;gap:8px">';
|
||||
for (const s of stocks) {
|
||||
html += `<span style="padding:4px 8px;background:#0f1419;border-radius:4px;font-size:12px">${s.name} <span style="color:var(--text-mute)">${s.code}</span></span>`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
document.getElementById('cal-list').innerHTML = html;
|
||||
};
|
||||
|
||||
document.getElementById('cal-load').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 财报排行榜
|
||||
VIEWS['fin-rankings'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>财报排行榜 <span class="sub">最新一期</span>
|
||||
<span class="seg" id="rank-seg" style="margin-left:auto"><button data-m="roe" class="active">ROE</button><button data-m="gross_margin">毛利率</button><button data-m="revenue_growth">营收增速</button></span></div>
|
||||
<div class="panel-body" style="padding:0"><div id="rank-list"></div></div></div>`;
|
||||
|
||||
const load = async (metric) => {
|
||||
document.getElementById('rank-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/financial/rankings?metric=${metric}&limit=30`);
|
||||
} catch {
|
||||
document.getElementById('rank-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('rank-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const metricName = { roe: 'ROE', gross_margin: '毛利率', revenue_growth: '营收增速' }[metric];
|
||||
const rows = r.rankings.map((s, i) => [
|
||||
`<td>${i + 1}</td>`,
|
||||
`<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="num">${fmt(s.roe)}%</td>`,
|
||||
`<td class="num">${fmt(s.gross_margin)}%</td>`,
|
||||
`<td class="${cls(s.revenue_growth)} num">${sign(s.revenue_growth)}${fmt(s.revenue_growth)}%</td>`,
|
||||
`<td class="${cls(s.profit_growth)} num">${sign(s.profit_growth)}${fmt(s.profit_growth)}%</td>`
|
||||
]);
|
||||
|
||||
document.getElementById('rank-list').innerHTML = rankTable(['排名', '名称', 'ROE', '毛利率', '营收增速', '利润增速'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('rank-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(e.target.dataset.m);
|
||||
};
|
||||
|
||||
load('roe');
|
||||
};
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智策 · 股票分析复盘终端</title>
|
||||
<title>Blackdata · 股票分析复盘终端</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- 顶部栏 -->
|
||||
<header class="topbar">
|
||||
<div class="brand">智策 <em>StockTerminal</em></div>
|
||||
<div class="brand">Blackdata <em>StockTerminal</em></div>
|
||||
<div class="search">
|
||||
<input type="text" placeholder="搜索股票 / 代码 / 板块 (Ctrl+K)" />
|
||||
</div>
|
||||
@@ -39,5 +39,13 @@
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="backtest-enhanced.js"></script>
|
||||
<script src="sector-rotation.js"></script>
|
||||
<script src="smart-selector.js"></script>
|
||||
<script src="ai-chat.js"></script>
|
||||
<script src="sentiment-monitor.js"></script>
|
||||
<script src="event-driven.js"></script>
|
||||
<script src="financial-analysis.js"></script>
|
||||
<script src="limit-analysis.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
275
prototype/limit-analysis.js
vendored
Normal file
275
prototype/limit-analysis.js
vendored
Normal file
@@ -0,0 +1,275 @@
|
||||
// 涨跌停分析
|
||||
|
||||
// 添加到菜单
|
||||
if (!MENU.find(g => g.name === '涨跌停')) {
|
||||
MENU.push({
|
||||
icon: '📈',
|
||||
name: '涨跌停',
|
||||
children: [
|
||||
{ id: 'limit-today', name: '今日涨跌停' },
|
||||
{ id: 'limit-consecutive', name: '连板追踪' },
|
||||
{ id: 'limit-break', name: '炸板率' },
|
||||
{ id: 'limit-squad', name: '敢死队' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 今日涨跌停
|
||||
VIEWS['limit-today'] = async function(view) {
|
||||
view.innerHTML = `<div class="row c2">
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>今日涨停 <span class="sub">涨幅≥9.8%</span>
|
||||
<button id="limit-up-load" class="btn-run" style="margin-left:auto">刷新</button></div>
|
||||
<div class="panel-body"><div id="limit-up-summary" style="padding:8px 12px;color:var(--text-dim);margin-bottom:8px"></div><div id="limit-up-list" style="max-height:600px;overflow-y:auto"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>今日跌停 <span class="sub">跌幅≥9.8%</span>
|
||||
<button id="limit-down-load" class="btn-run" style="margin-left:auto">刷新</button></div>
|
||||
<div class="panel-body"><div id="limit-down-summary" style="padding:8px 12px;color:var(--text-dim);margin-bottom:8px"></div><div id="limit-down-list" style="max-height:600px;overflow-y:auto"></div></div></div>
|
||||
</div>`;
|
||||
|
||||
const loadUp = async () => {
|
||||
document.getElementById('limit-up-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/limit/stocks?limit_type=up');
|
||||
} catch {
|
||||
document.getElementById('limit-up-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('limit-up-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('limit-up-summary').innerHTML = `${r.date} 共 <b style="color:var(--up)">${r.count}</b> 只`;
|
||||
|
||||
if (!r.count) {
|
||||
document.getElementById('limit-up-list').innerHTML = '<div class="loading">今日无涨停</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.stocks.map((s, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
|
||||
<td class="up num">${sign(s.pct)}${fmt(s.pct)}%</td>
|
||||
<td class="num">${fmt(s.close)}</td>
|
||||
<td class="num">${fmt(s.amount / 100, 1)}亿</td>
|
||||
</tr>`).join('');
|
||||
|
||||
document.getElementById('limit-up-list').innerHTML = `<table class="grid-tbl"><thead><tr><th>排名</th><th>名称</th><th>涨幅</th><th>现价</th><th>成交额</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
};
|
||||
|
||||
const loadDown = async () => {
|
||||
document.getElementById('limit-down-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/limit/stocks?limit_type=down');
|
||||
} catch {
|
||||
document.getElementById('limit-down-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('limit-down-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('limit-down-summary').innerHTML = `${r.date} 共 <b style="color:var(--down)">${r.count}</b> 只`;
|
||||
|
||||
if (!r.count) {
|
||||
document.getElementById('limit-down-list').innerHTML = '<div class="loading">今日无跌停</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.stocks.map((s, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
|
||||
<td class="down num">${sign(s.pct)}${fmt(s.pct)}%</td>
|
||||
<td class="num">${fmt(s.close)}</td>
|
||||
<td class="num">${fmt(s.amount / 100, 1)}亿</td>
|
||||
</tr>`).join('');
|
||||
|
||||
document.getElementById('limit-down-list').innerHTML = `<table class="grid-tbl"><thead><tr><th>排名</th><th>名称</th><th>跌幅</th><th>现价</th><th>成交额</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
};
|
||||
|
||||
document.getElementById('limit-up-load').onclick = loadUp;
|
||||
document.getElementById('limit-down-load').onclick = loadDown;
|
||||
|
||||
loadUp();
|
||||
loadDown();
|
||||
};
|
||||
|
||||
// 连板追踪
|
||||
VIEWS['limit-consecutive'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>连板股追踪器 <span class="sub">几进几?</span>
|
||||
<span class="seg" id="cons-seg" style="margin-left:auto"><button data-d="5">5日</button><button data-d="10" class="active">10日</button><button data-d="20">20日</button></span></div>
|
||||
<div class="panel-body">
|
||||
<div id="cons-summary" style="padding:8px 12px;color:var(--text-dim);margin-bottom:8px"></div>
|
||||
<div id="cons-list"></div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
document.getElementById('cons-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/limit/consecutive?days=${days}`);
|
||||
} catch {
|
||||
document.getElementById('cons-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('cons-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('cons-summary').innerHTML = `${r.date} 近${r.days}日共 <b style="color:var(--up)">${r.count}</b> 只连板股`;
|
||||
|
||||
if (!r.count) {
|
||||
document.getElementById('cons-list').innerHTML = '<div class="loading">暂无连板股</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.stocks.map((s, i) => [
|
||||
`<td>${i + 1}</td>`,
|
||||
`<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="up" style="font-size:18px;font-weight:600">${s.consecutive_days}连板</td>`,
|
||||
`<td class="num">${fmt(s.close)}</td>`,
|
||||
`<td class="num">${fmt(s.amount / 100, 1)}亿</td>`
|
||||
]);
|
||||
|
||||
document.getElementById('cons-list').innerHTML = rankTable(['排名', '名称', '连板数', '现价', '成交额'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('cons-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(10);
|
||||
};
|
||||
|
||||
// 炸板率统计
|
||||
VIEWS['limit-break'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>炸板率统计 <span class="sub">涨停后次日表现</span>
|
||||
<button id="break-load" class="btn-run" style="margin-left:auto">刷新</button></div>
|
||||
<div class="panel-body">
|
||||
<div id="break-summary" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div id="break-chart" style="height:280px;margin-bottom:12px"></div>
|
||||
<div id="break-list"></div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
document.getElementById('break-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">统计中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/limit/break_rate?days=60');
|
||||
} catch {
|
||||
document.getElementById('break-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('break-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = r.next_day_stats;
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('break-summary').innerHTML =
|
||||
card('样本数', r.total_samples) +
|
||||
card('继续涨停', s.limit_up.pct + '%', 'up') +
|
||||
card('上涨未涨停', s.up.pct + '%') +
|
||||
card('炸板率', r.overall_break_rate + '%', 'down');
|
||||
|
||||
// 饼图
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('break-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, textStyle: { color: AXIS } },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '45%'],
|
||||
data: [
|
||||
{ value: s.limit_up.count, name: `继续涨停(${s.limit_up.pct}%)`, itemStyle: { color: '#2ebd85' } },
|
||||
{ value: s.up.count, name: `上涨未涨停(${s.up.pct}%)`, itemStyle: { color: '#e8a13a' } },
|
||||
{ value: s.down.count, name: `下跌(${s.down.pct}%)`, itemStyle: { color: '#f6465d' } },
|
||||
{ value: s.limit_down.count, name: `跌停(${s.limit_down.pct}%)`, itemStyle: { color: '#5a2630' } }
|
||||
],
|
||||
label: { color: AXIS }
|
||||
}]
|
||||
});
|
||||
|
||||
// 个股炸板率排行
|
||||
if (r.stock_rankings && r.stock_rankings.length) {
|
||||
const rows = r.stock_rankings.slice(0, 20).map((s, i) => [
|
||||
`<td>${i + 1}</td>`,
|
||||
`<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="num">${s.limits}</td>`,
|
||||
`<td class="num">${s.breaks}</td>`,
|
||||
`<td class="down num">${s.break_rate}%</td>`
|
||||
]);
|
||||
document.getElementById('break-list').innerHTML = `<div style="padding:8px 0;font-weight:600">个股炸板率排行(炸板率高=不稳定)</div>` + rankTable(['排名', '名称', '涨停次数', '炸板次数', '炸板率'], rows);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('break-load').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 涨停敢死队
|
||||
VIEWS['limit-squad'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>涨停敢死队排行 <span class="sub">期间涨停次数最多</span>
|
||||
<span class="seg" id="squad-seg" style="margin-left:auto"><button data-d="20">20日</button><button data-d="30" class="active">30日</button><button data-d="60">60日</button></span></div>
|
||||
<div class="panel-body">
|
||||
<div id="squad-summary" style="padding:8px 12px;color:var(--text-dim);margin-bottom:8px"></div>
|
||||
<div id="squad-list"></div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
document.getElementById('squad-list').innerHTML = '<div class="loading">统计中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/limit/squad?days=${days}&min_limits=5`);
|
||||
} catch {
|
||||
document.getElementById('squad-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('squad-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('squad-summary').innerHTML = `${r.start_date} ~ ${r.end_date} 共 <b style="color:var(--up)">${r.count}</b> 只妖股(涨停≥5次)`;
|
||||
|
||||
if (!r.count) {
|
||||
document.getElementById('squad-list').innerHTML = '<div class="loading">暂无符合条件的股票</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.rankings.map((s, i) => [
|
||||
`<td>${i + 1}</td>`,
|
||||
`<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="up num" style="font-size:18px;font-weight:600">${s.limit_count}次</td>`,
|
||||
`<td class="num">${s.max_consecutive}连板</td>`,
|
||||
`<td class="num">${s.frequency}%</td>`,
|
||||
`<td style="font-size:11px;color:var(--text-mute)">${s.dates.slice(-5).join(', ')}</td>`
|
||||
]);
|
||||
|
||||
document.getElementById('squad-list').innerHTML = rankTable(['排名', '名称', '涨停次数', '最大连板', '涨停频率', '最近涨停日期'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('squad-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(30);
|
||||
};
|
||||
273
prototype/sector-rotation.js
vendored
Normal file
273
prototype/sector-rotation.js
vendored
Normal file
@@ -0,0 +1,273 @@
|
||||
// 板块轮动分析功能
|
||||
|
||||
// 添加到菜单
|
||||
if (!MENU.find(g => g.name === '板块分析')) {
|
||||
MENU.splice(2, 0, {
|
||||
icon: '◐',
|
||||
name: '板块分析',
|
||||
children: [
|
||||
{ id: 'sector-trend', name: '强弱趋势' },
|
||||
{ id: 'sector-flow', name: '资金流向' },
|
||||
{ id: 'sector-lifecycle', name: '生命周期' },
|
||||
{ id: 'sector-leaders', name: '龙头股' },
|
||||
{ id: 'sector-correlation', name: '联动性' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 板块强弱趋势
|
||||
VIEWS['sector-trend'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>板块强弱趋势 <span class="sub">近期涨跌幅排名</span>
|
||||
<span class="seg" id="trend-seg"><button data-d="5" class="active">5日</button><button data-d="10">10日</button><button data-d="20">20日</button></span></div>
|
||||
<div class="panel-body"><div id="trend-chart" style="height:480px"></div></div></div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
let r; try { r = await apiGet(`/api/sector/trend?days=${days}&top_n=15`); } catch { document.getElementById('trend-chart').innerHTML='<div class="loading">后端未连接</div>'; return; }
|
||||
if (!r.ok) { document.getElementById('trend-chart').innerHTML=`<div class="loading">${r.msg}</div>`; return; }
|
||||
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('trend-chart'));
|
||||
|
||||
// 多条折线图
|
||||
const series = r.sectors.slice(0, 10).map((s, i) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: s.pcts,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 1.5 }
|
||||
}));
|
||||
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } },
|
||||
legend: { data: r.sectors.slice(0, 10).map(s => s.name), textStyle: { color: AXIS }, top: 0, type: 'scroll' },
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: { type: 'category', data: r.sectors[0].dates, axisLabel: { color: AXIS }, axisLine: { lineStyle: { color: GRID } } },
|
||||
yAxis: { type: 'value', axisLabel: { color: AXIS, formatter: v => v + '%' }, splitLine: { lineStyle: { color: GRID } } },
|
||||
dataZoom: [{ type: 'inside' }, { type: 'slider', bottom: 5, height: 14, textStyle: { color: AXIS }, borderColor: GRID }],
|
||||
series
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('trend-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(5);
|
||||
};
|
||||
|
||||
// 资金流向
|
||||
VIEWS['sector-flow'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>资金流向桑基图 <span class="sub">板块间资金流动</span>
|
||||
<span class="seg" id="flow-seg"><button data-d="1">1日</button><button data-d="3">3日</button><button data-d="5" class="active">5日</button><button data-d="10">10日</button></span></div>
|
||||
<div class="panel-body"><div id="flow-chart" style="height:480px"></div></div></div>
|
||||
<div class="row c2">
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>资金净流入 Top</div><div class="panel-body" style="padding:0"><div id="flow-in"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>资金净流出 Top</div><div class="panel-body" style="padding:0"><div id="flow-out"></div></div></div>
|
||||
</div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
let r; try { r = await apiGet(`/api/sector/flow?days=${days}`); } catch { document.getElementById('flow-chart').innerHTML='<div class="loading">后端未连接</div>'; return; }
|
||||
if (!r.ok) { document.getElementById('flow-chart').innerHTML=`<div class="loading">${r.msg}</div>`; return; }
|
||||
|
||||
// 桑基图
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('flow-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'item', backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } },
|
||||
series: [{
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
emphasis: { focus: 'adjacency' },
|
||||
data: r.sankey.nodes,
|
||||
links: r.sankey.links,
|
||||
lineStyle: { color: 'gradient', curveness: 0.5 },
|
||||
itemStyle: { borderWidth: 1, borderColor: '#0a0e15' },
|
||||
label: { color: AXIS, fontSize: 11 }
|
||||
}]
|
||||
});
|
||||
|
||||
// 表格
|
||||
const inRows = r.top_inflow.map(x => `<tr><td>${x[0]}</td><td class="up num">${sign(x[1])}${fmt(x[1])}亿</td></tr>`).join('');
|
||||
const outRows = r.top_outflow.map(x => `<tr><td>${x[0]}</td><td class="down num">-${fmt(x[1])}亿</td></tr>`).join('');
|
||||
document.getElementById('flow-in').innerHTML = `<table class="grid-tbl"><tbody>${inRows}</tbody></table>`;
|
||||
document.getElementById('flow-out').innerHTML = `<table class="grid-tbl"><tbody>${outRows}</tbody></table>`;
|
||||
};
|
||||
|
||||
document.getElementById('flow-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(5);
|
||||
};
|
||||
|
||||
// 板块生命周期
|
||||
VIEWS['sector-lifecycle'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>板块生命周期分析 <span class="sub">判断板块所处阶段</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<input id="lc-sector" value="半导体" style="width:120px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px" placeholder="输入板块名称"/>
|
||||
<button id="lc-run" class="btn-run">分析</button>
|
||||
</span></div>
|
||||
<div class="panel-body">
|
||||
<div id="lc-phase" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div id="lc-desc" style="padding:12px;background:#0f1419;border-radius:4px;margin-bottom:12px"></div>
|
||||
<div id="lc-chart" style="height:320px"></div>
|
||||
</div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
const name = document.getElementById('lc-sector').value.trim();
|
||||
if (!name) { alert('请输入板块名称'); return; }
|
||||
document.getElementById('lc-phase').innerHTML = '<div class="loading">分析中…</div>';
|
||||
let r; try { r = await apiGet(`/api/sector/lifecycle?name=${encodeURIComponent(name)}&days=60`); } catch { document.getElementById('lc-phase').innerHTML='后端未连接'; return; }
|
||||
if (!r.ok) { document.getElementById('lc-phase').innerHTML=r.msg; return; }
|
||||
|
||||
const phaseColor = { '启动期': 'up', '加速期': 'up', '衰退期': 'down', '下跌期': 'down', '震荡期': '' };
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:18px">${v}</div></div>`;
|
||||
const m = r.metrics;
|
||||
document.getElementById('lc-phase').innerHTML =
|
||||
card('生命周期', r.phase, phaseColor[r.phase]) +
|
||||
card('5日涨幅', sign(m.return_5d) + m.return_5d + '%', cls(m.return_5d)) +
|
||||
card('20日涨幅', sign(m.return_20d) + m.return_20d + '%', cls(m.return_20d)) +
|
||||
card('成交额变化', sign(m.amount_change) + m.amount_change + '%', cls(m.amount_change));
|
||||
|
||||
document.getElementById('lc-desc').innerHTML = `<b>${r.sector}</b> ${r.description}`;
|
||||
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('lc-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } },
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 40 },
|
||||
xAxis: { type: 'category', data: r.dates, axisLabel: { color: AXIS }, axisLine: { lineStyle: { color: GRID } } },
|
||||
yAxis: { type: 'value', axisLabel: { color: AXIS, formatter: v => v + '%' }, splitLine: { lineStyle: { color: GRID } } },
|
||||
dataZoom: [{ type: 'inside' }, { type: 'slider', bottom: 5, height: 14, textStyle: { color: AXIS }, borderColor: GRID }],
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: r.pcts,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#e8a13a' },
|
||||
areaStyle: { color: '#e8a13a22' }
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('lc-run').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 龙头股识别
|
||||
VIEWS['sector-leaders'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>板块龙头股 <span class="sub">涨幅+成交额排名</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<input id="ld-sector" value="半导体" style="width:120px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<button id="ld-run" class="btn-run">查询</button>
|
||||
</span></div>
|
||||
<div class="panel-body" style="padding:0"><div id="ld-list"></div></div></div>`;
|
||||
|
||||
const load = async () => {
|
||||
const name = document.getElementById('ld-sector').value.trim();
|
||||
if (!name) { alert('请输入板块名称'); return; }
|
||||
document.getElementById('ld-list').innerHTML = '<div class="loading">查询中…</div>';
|
||||
let r; try { r = await apiGet(`/api/sector/leaders?name=${encodeURIComponent(name)}&days=20&limit=15`); } catch { document.getElementById('ld-list').innerHTML='后端未连接'; return; }
|
||||
if (!r.ok) { document.getElementById('ld-list').innerHTML=`<div class="loading">${r.msg}</div>`; return; }
|
||||
|
||||
if (!r.leaders.length) {
|
||||
document.getElementById('ld-list').innerHTML = '<div class="loading">暂无数据(提示:目前基于名称关键词匹配,实际应建立股票-板块映射表)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.leaders.map((s, i) => [
|
||||
`<td>${i + 1} <b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="num">${fmt(s.close)}</td>`,
|
||||
`<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td>`,
|
||||
`<td class="${cls(s.ret5)} num">${sign(s.ret5)}${fmt(s.ret5)}%</td>`,
|
||||
`<td class="${cls(s.ret20)} num">${sign(s.ret20)}${fmt(s.ret20)}%</td>`,
|
||||
`<td class="num">${fmt(s.amount, 1)}亿</td>`,
|
||||
`<td class="num">${fmt(s.vol_ratio, 2)}</td>`
|
||||
]);
|
||||
document.getElementById('ld-list').innerHTML = rankTable(['排名/名称', '现价', '涨跌幅', '5日', '20日', '成交额', '量比'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('ld-run').onclick = load;
|
||||
load();
|
||||
};
|
||||
|
||||
// 板块联动性
|
||||
VIEWS['sector-correlation'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>板块相关性热力图 <span class="sub">联动性分析</span></div>
|
||||
<div class="panel-body"><div id="corr-chart" style="height:520px"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>高度相关板块对(相关系数 > 0.7)</div>
|
||||
<div class="panel-body" style="padding:0"><div id="corr-list"></div></div></div>`;
|
||||
|
||||
let r; try { r = await apiGet('/api/sector/correlation?days=60&top_n=20'); } catch { view.innerHTML='<div class="panel"><div class="loading">后端未连接</div></div>'; return; }
|
||||
if (!r.ok) { view.innerHTML=`<div class="panel"><div class="loading">${r.msg}</div></div>`; return; }
|
||||
|
||||
// 热力图
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('corr-chart'));
|
||||
const max = Math.max(...r.heatmap.map(d => Math.abs(d.value)));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
backgroundColor: '#161d29',
|
||||
borderColor: GRID,
|
||||
textStyle: { color: '#d7dee8' },
|
||||
formatter: p => `${r.sectors[p.data[1]]} vs ${r.sectors[p.data[0]]}<br/>相关系数: ${p.data[2]}`
|
||||
},
|
||||
grid: { left: 100, right: 20, top: 20, bottom: 100 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: r.sectors,
|
||||
axisLabel: { color: AXIS, rotate: 45, fontSize: 10 },
|
||||
splitArea: { show: true }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: r.sectors,
|
||||
axisLabel: { color: AXIS, fontSize: 10 },
|
||||
splitArea: { show: true }
|
||||
},
|
||||
visualMap: {
|
||||
min: -max,
|
||||
max: max,
|
||||
calculable: true,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: '5%',
|
||||
inRange: {
|
||||
color: ['#2ebd85', '#f5f5f5', '#f6465d']
|
||||
},
|
||||
textStyle: { color: AXIS }
|
||||
},
|
||||
series: [{
|
||||
type: 'heatmap',
|
||||
data: r.heatmap.map(d => [d.x, d.y, d.value]),
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// 高相关列表
|
||||
if (r.high_correlation.length) {
|
||||
const rows = r.high_correlation.map(x => [
|
||||
`<td>${x.sector1}</td>`,
|
||||
`<td>${x.sector2}</td>`,
|
||||
`<td class="num">${x.correlation}</td>`
|
||||
]);
|
||||
document.getElementById('corr-list').innerHTML = rankTable(['板块1', '板块2', '相关系数'], rows);
|
||||
} else {
|
||||
document.getElementById('corr-list').innerHTML = '<div class="loading">暂无高度相关的板块对</div>';
|
||||
}
|
||||
};
|
||||
271
prototype/sentiment-monitor.js
vendored
Normal file
271
prototype/sentiment-monitor.js
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
// 社区情绪监控
|
||||
|
||||
// 添加到菜单
|
||||
if (!MENU.find(g => g.name === '社区情绪')) {
|
||||
MENU.push({
|
||||
icon: '💬',
|
||||
name: '社区情绪',
|
||||
children: [
|
||||
{ id: 'sentiment-index', name: '情绪指数' },
|
||||
{ id: 'sentiment-hot', name: '热议股票' },
|
||||
{ id: 'sentiment-wordcloud', name: '关键词云' },
|
||||
{ id: 'sentiment-correlation', name: '情绪相关性' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 情绪指数
|
||||
VIEWS['sentiment-index'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>社区情绪指数 <span class="sub">量化散户情绪</span>
|
||||
<button id="sent-collect" class="btn-run" style="margin-left:auto">采集数据</button>
|
||||
<button id="sent-calc" class="btn-run" style="background:#2a3140;border-color:#2a3140">计算指数</button></div>
|
||||
<div class="panel-body">
|
||||
<div id="sent-summary" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div id="sent-keywords" style="margin-bottom:12px"></div>
|
||||
</div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>情绪指数历史(30日)</div>
|
||||
<div class="panel-body"><div id="sent-chart" style="height:360px"></div></div></div>`;
|
||||
|
||||
const loadIndex = async () => {
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/sentiment/index');
|
||||
} catch {
|
||||
document.getElementById('sent-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('sent-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('sent-summary').innerHTML =
|
||||
card('乐观', r.bullish_count, 'up') +
|
||||
card('悲观', r.bearish_count, 'down') +
|
||||
card('中性', r.neutral_count) +
|
||||
card('乐观比例', r.bullish_ratio + '%', r.bullish_ratio >= 60 ? 'up' : (r.bullish_ratio <= 40 ? 'down' : ''));
|
||||
|
||||
// 热词
|
||||
if (r.top_keywords && r.top_keywords.length) {
|
||||
const tags = r.top_keywords.slice(0, 15).map(kw => `<span class="tag" style="margin:4px">${kw.word}(${kw.count})</span>`).join('');
|
||||
document.getElementById('sent-keywords').innerHTML = `<div style="padding:12px;background:#0f1419;border-radius:4px"><b>热门关键词</b> ${tags}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/sentiment/history?days=30');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) return;
|
||||
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('sent-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } },
|
||||
legend: { data: ['乐观比例', '帖子数'], textStyle: { color: AXIS }, top: 0 },
|
||||
grid: { left: 50, right: 50, top: 30, bottom: 30 },
|
||||
xAxis: { type: 'category', data: r.dates, axisLabel: { color: AXIS }, axisLine: { lineStyle: { color: GRID } } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '乐观比例(%)', axisLabel: { color: AXIS }, splitLine: { lineStyle: { color: GRID } } },
|
||||
{ type: 'value', name: '帖子数', axisLabel: { color: AXIS }, splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{ name: '乐观比例', type: 'line', data: r.bullish_ratio, smooth: true, lineStyle: { width: 2, color: '#e8a13a' }, areaStyle: { color: '#e8a13a22' } },
|
||||
{ name: '帖子数', type: 'bar', yAxisIndex: 1, data: r.total_posts, itemStyle: { color: '#2f6fed33' } }
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('sent-collect').onclick = async () => {
|
||||
document.getElementById('sent-collect').textContent = '采集中...';
|
||||
document.getElementById('sent-collect').disabled = true;
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/sentiment/collect?limit=100', { method: 'POST' }).then(x => x.json());
|
||||
alert(r.ok ? `采集完成!共${r.collected}条,保存${r.saved}条` : `采集失败:${r.msg}`);
|
||||
} catch {
|
||||
alert('采集失败');
|
||||
} finally {
|
||||
document.getElementById('sent-collect').textContent = '采集数据';
|
||||
document.getElementById('sent-collect').disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('sent-calc').onclick = async () => {
|
||||
document.getElementById('sent-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">计算中...</div></div>';
|
||||
await loadIndex();
|
||||
await loadHistory();
|
||||
};
|
||||
|
||||
loadIndex();
|
||||
loadHistory();
|
||||
};
|
||||
|
||||
// 热议股票
|
||||
VIEWS['sentiment-hot'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>热议股票排行 <span class="sub">讨论量激增预警</span>
|
||||
<span class="seg" id="hot-seg" style="margin-left:auto"><button data-d="1" class="active">今日</button><button data-d="3">3日</button><button data-d="7">7日</button></span></div>
|
||||
<div class="panel-body" style="padding:0"><div id="hot-list"></div></div></div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
document.getElementById('hot-list').innerHTML = '<div class="loading">加载中...</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/sentiment/hot_stocks?days=${days}&limit=30`);
|
||||
} catch {
|
||||
document.getElementById('hot-list').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('hot-list').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.stocks.map((s, i) => [
|
||||
`<td>${i + 1} <b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>`,
|
||||
`<td class="num">${s.post_count}</td>`,
|
||||
`<td class="num">${s.total_comments}</td>`,
|
||||
`<td class="num">${(s.total_views / 10000).toFixed(1)}万</td>`,
|
||||
`<td class="num" style="color:var(--gold)">${s.heat_score}</td>`,
|
||||
`<td class="num">${fmt(s.close)}</td>`,
|
||||
`<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td>`
|
||||
]);
|
||||
|
||||
document.getElementById('hot-list').innerHTML = rankTable(['名称/代码', '帖子数', '评论数', '浏览量', '热度', '现价', '涨跌幅'], rows);
|
||||
};
|
||||
|
||||
document.getElementById('hot-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(1);
|
||||
};
|
||||
|
||||
// 关键词云
|
||||
VIEWS['sentiment-wordcloud'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>关键词云图 <span class="sub">当前市场关注什么</span>
|
||||
<span class="seg" id="wc-seg" style="margin-left:auto"><button data-d="1">1日</button><button data-d="3">3日</button><button data-d="7" class="active">7日</button></span></div>
|
||||
<div class="panel-body"><div id="wc-chart" style="height:520px"></div></div></div>`;
|
||||
|
||||
const load = async (days) => {
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/sentiment/wordcloud?days=${days}&top_n=60`);
|
||||
} catch {
|
||||
document.getElementById('wc-chart').innerHTML = '<div class="loading">后端未连接</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('wc-chart').innerHTML = `<div class="loading">${r.msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('wc-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { show: true },
|
||||
series: [{
|
||||
type: 'wordCloud',
|
||||
gridSize: 8,
|
||||
sizeRange: [12, 50],
|
||||
rotationRange: [0, 0],
|
||||
shape: 'circle',
|
||||
textStyle: {
|
||||
fontFamily: 'sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: function() {
|
||||
const colors = ['#e8a13a', '#2f6fed', '#a855f7', '#06b6d4', '#f6465d', '#2ebd85'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
},
|
||||
data: r.keywords
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('wc-seg').onclick = e => {
|
||||
if (e.target.tagName !== 'BUTTON') return;
|
||||
[...e.currentTarget.children].forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
load(+e.target.dataset.d);
|
||||
};
|
||||
|
||||
load(7);
|
||||
};
|
||||
|
||||
// 情绪相关性
|
||||
VIEWS['sentiment-correlation'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>情绪与股价相关性 <span class="sub">反向指标验证</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<input id="corr-code" value="600519" style="width:90px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<button id="corr-run" class="btn-run">分析</button>
|
||||
</span></div>
|
||||
<div class="panel-body">
|
||||
<div id="corr-summary" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div id="corr-desc" style="padding:12px;background:#0f1419;border-radius:4px;margin-bottom:12px"></div>
|
||||
<div id="corr-chart" style="height:360px"></div>
|
||||
</div></div>`;
|
||||
|
||||
const analyze = async () => {
|
||||
const code = document.getElementById('corr-code').value.trim();
|
||||
if (!code) { alert('请输入股票代码'); return; }
|
||||
|
||||
document.getElementById('corr-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">分析中...</div></div>';
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet(`/api/sentiment/correlation?code=${code}&days=60`);
|
||||
} catch {
|
||||
document.getElementById('corr-summary').innerHTML = '<div style="grid-column:1/-1"><div class="loading">后端未连接</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('corr-summary').innerHTML = `<div style="grid-column:1/-1"><div class="loading">${r.msg}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:20px">${v}</div></div>`;
|
||||
const corrColor = r.correlation > 0.3 ? '' : (r.correlation < -0.3 ? 'down' : '');
|
||||
document.getElementById('corr-summary').innerHTML =
|
||||
card('相关系数', r.correlation, corrColor) +
|
||||
card('数据点数', r.data_points) +
|
||||
card('分析周期', r.days + '天') +
|
||||
card('股票代码', r.code);
|
||||
|
||||
document.getElementById('corr-desc').innerHTML = `<b>结论</b>:${r.interpretation}<br/><br/>
|
||||
<b>说明</b>:相关系数范围 -1 到 1。正值表示同向(情绪高涨时股价上涨),负值表示反向(典型的反向指标)。`;
|
||||
|
||||
disposeCharts();
|
||||
const c = newChart(document.getElementById('corr-chart'));
|
||||
c.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } },
|
||||
legend: { data: ['情绪指数', '股价涨跌'], textStyle: { color: AXIS }, top: 0 },
|
||||
grid: { left: 50, right: 50, top: 30, bottom: 30 },
|
||||
xAxis: { type: 'category', data: r.dates, axisLabel: { color: AXIS }, axisLine: { lineStyle: { color: GRID } } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '情绪(%)', axisLabel: { color: AXIS }, splitLine: { lineStyle: { color: GRID } } },
|
||||
{ type: 'value', name: '涨跌(%)', axisLabel: { color: AXIS }, splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{ name: '情绪指数', type: 'line', data: r.sentiment_scores, smooth: true, lineStyle: { width: 2, color: '#e8a13a' } },
|
||||
{ name: '股价涨跌', type: 'line', yAxisIndex: 1, data: r.price_changes, smooth: true, lineStyle: { width: 2, color: '#2f6fed' } }
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('corr-run').onclick = analyze;
|
||||
analyze();
|
||||
};
|
||||
346
prototype/smart-selector.js
vendored
Normal file
346
prototype/smart-selector.js
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
// 智能选股增强功能
|
||||
|
||||
let _SELECTOR_FIELDS = [];
|
||||
let _CURRENT_STRATEGY = { name: "自定义策略", description: "", logic: "and", conditions: [] };
|
||||
let _SELECTOR_RESULTS = [];
|
||||
|
||||
// 替换原有的选股视图
|
||||
VIEWS['screen-strat'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>智能选股器 <span class="sub">可视化多条件组合</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<button id="sel-preset" class="btn-run" style="background:#2a3140;border-color:#2a3140">加载预设</button>
|
||||
<button id="sel-save" class="btn-run" style="background:#2a3140;border-color:#2a3140">保存策略</button>
|
||||
<button id="sel-backtest" class="btn-run" style="background:#2a3140;border-color:#2a3140">回测验证</button>
|
||||
</span></div>
|
||||
<div class="panel-body">
|
||||
<div style="margin-bottom:12px;display:flex;gap:10px;align-items:center">
|
||||
<b>策略名称</b> <input id="sel-name" value="自定义策略" style="width:150px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<b>逻辑</b> <select id="sel-logic" style="height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text)"><option value="and">AND(全部满足)</option><option value="or">OR(任一满足)</option></select>
|
||||
</div>
|
||||
<div id="sel-conditions" style="margin-bottom:12px"></div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px">
|
||||
<button id="sel-add" class="btn-run">➕ 添加条件</button>
|
||||
<button id="sel-run" class="btn-run">🔍 执行选股</button>
|
||||
</div>
|
||||
<div id="sel-result"></div>
|
||||
</div></div>`;
|
||||
|
||||
// 加载字段定义
|
||||
try {
|
||||
const r = await apiGet('/api/selector/fields');
|
||||
if (r.ok) _SELECTOR_FIELDS = r.fields;
|
||||
} catch {}
|
||||
|
||||
const renderConditions = () => {
|
||||
const html = _CURRENT_STRATEGY.conditions.map((cond, i) => {
|
||||
const field = _SELECTOR_FIELDS.find(f => f.field === cond.field) || {};
|
||||
return `<div class="sel-cond" style="display:flex;gap:8px;align-items:center;margin-bottom:6px;padding:8px;background:#0f1419;border-radius:4px">
|
||||
<span style="min-width:30px">${i + 1}.</span>
|
||||
<select class="cond-field" data-i="${i}" style="width:120px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text)">
|
||||
${_SELECTOR_FIELDS.map(f => `<option value="${f.field}" ${f.field === cond.field ? 'selected' : ''}>${f.name}</option>`).join('')}
|
||||
</select>
|
||||
<select class="cond-op" data-i="${i}" style="width:80px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text)">
|
||||
<option value=">" ${cond.operator === '>' ? 'selected' : ''}>></option>
|
||||
<option value=">=" ${cond.operator === '>=' ? 'selected' : ''}>>=</option>
|
||||
<option value="<" ${cond.operator === '<' ? 'selected' : ''}><</option>
|
||||
<option value="<=" ${cond.operator === '<=' ? 'selected' : ''}><=</option>
|
||||
</select>
|
||||
<input class="cond-val" data-i="${i}" type="number" step="any" value="${cond.value}" style="width:100px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px"/>
|
||||
<span style="color:var(--text-mute)">${field.unit || ''}</span>
|
||||
<button class="sel-del" data-i="${i}" style="margin-left:auto;padding:4px 12px;background:#5a2630;border:1px solid #5a2630;color:var(--text);cursor:pointer;border-radius:4px">删除</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.getElementById('sel-conditions').innerHTML = html || '<div style="color:var(--text-dim);padding:12px">暂无条件,点击「添加条件」开始</div>';
|
||||
|
||||
// 绑定事件
|
||||
document.querySelectorAll('.cond-field').forEach(el => el.onchange = () => {
|
||||
const i = +el.dataset.i;
|
||||
_CURRENT_STRATEGY.conditions[i].field = el.value;
|
||||
});
|
||||
document.querySelectorAll('.cond-op').forEach(el => el.onchange = () => {
|
||||
const i = +el.dataset.i;
|
||||
_CURRENT_STRATEGY.conditions[i].operator = el.value;
|
||||
});
|
||||
document.querySelectorAll('.cond-val').forEach(el => el.oninput = () => {
|
||||
const i = +el.dataset.i;
|
||||
_CURRENT_STRATEGY.conditions[i].value = +el.value;
|
||||
});
|
||||
document.querySelectorAll('.sel-del').forEach(el => el.onclick = () => {
|
||||
const i = +el.dataset.i;
|
||||
_CURRENT_STRATEGY.conditions.splice(i, 1);
|
||||
renderConditions();
|
||||
});
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
const firstField = _SELECTOR_FIELDS[0];
|
||||
if (!firstField) return;
|
||||
_CURRENT_STRATEGY.conditions.push({ field: firstField.field, operator: '>', value: 0 });
|
||||
renderConditions();
|
||||
};
|
||||
|
||||
const runSelector = async () => {
|
||||
_CURRENT_STRATEGY.name = document.getElementById('sel-name').value.trim();
|
||||
_CURRENT_STRATEGY.logic = document.getElementById('sel-logic').value;
|
||||
|
||||
if (!_CURRENT_STRATEGY.conditions.length) {
|
||||
alert('请至少添加一个条件'); return;
|
||||
}
|
||||
|
||||
document.getElementById('sel-result').innerHTML = '<div class="loading">选股中…</div>';
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(API_BASE + '/api/selector/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strategy: _CURRENT_STRATEGY })
|
||||
}).then(x => x.json());
|
||||
} catch {
|
||||
document.getElementById('sel-result').innerHTML = '后端未连接';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('sel-result').innerHTML = r.msg || '选股失败';
|
||||
return;
|
||||
}
|
||||
|
||||
_SELECTOR_RESULTS = r.results;
|
||||
const head = `<div style="padding:8px 12px;color:var(--text-dim);font-size:12px">选股日期:${r.date} 命中 <b style="color:var(--text)">${r.count}</b> 只</div>`;
|
||||
|
||||
if (!r.count) {
|
||||
document.getElementById('sel-result').innerHTML = head + '<div class="loading">无符合条件的股票</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = r.results.map((s, i) => `<tr><td>${i + 1} <b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
|
||||
<td class="num">${fmt(s.close)}</td>
|
||||
<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td>
|
||||
<td class="${cls(s.ret5)} num">${sign(s.ret5)}${fmt(s.ret5)}%</td>
|
||||
<td class="${cls(s.ret20)} num">${sign(s.ret20)}${fmt(s.ret20)}%</td>
|
||||
<td class="num">${fmt(s.vol_ratio, 2)}</td>
|
||||
<td class="num">${fmt(s.amount, 1)}亿</td></tr>`).join('');
|
||||
|
||||
document.getElementById('sel-result').innerHTML = head + `<table class="grid-tbl"><thead><tr>
|
||||
<th>名称/代码</th><th>现价</th><th>涨跌幅</th><th>5日</th><th>20日</th><th>量比</th><th>成交额</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table>`;
|
||||
};
|
||||
|
||||
const loadPreset = async () => {
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet('/api/selector/presets');
|
||||
} catch {
|
||||
alert('加载失败');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.ok || !r.presets.length) {
|
||||
alert('无预设策略');
|
||||
return;
|
||||
}
|
||||
|
||||
const presetId = prompt('选择预设策略:\n' + r.presets.map((p, i) => `${i + 1}. ${p.name} - ${p.description}`).join('\n') + '\n\n输入序号(1-' + r.presets.length + ')');
|
||||
if (!presetId) return;
|
||||
|
||||
const idx = +presetId - 1;
|
||||
if (idx < 0 || idx >= r.presets.length) {
|
||||
alert('无效序号');
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = r.presets[idx];
|
||||
// 加载预设策略的完整定义
|
||||
const presetStrategies = {
|
||||
'momentum': { name: '动量突破', description: '短期强势+放量', logic: 'and', conditions: [
|
||||
{ field: 'ret5', operator: '>', value: 10 },
|
||||
{ field: 'vol_ratio', operator: '>', value: 2 },
|
||||
{ field: 'rsi14', operator: '<', value: 80 }
|
||||
]},
|
||||
'value': { name: '价值洼地', description: '超跌低位+基本面支撑', logic: 'and', conditions: [
|
||||
{ field: 'pos60', operator: '<', value: 0.3 },
|
||||
{ field: 'pct', operator: '>', value: 0 },
|
||||
{ field: 'amount', operator: '>', value: 5 }
|
||||
]},
|
||||
'growth': { name: '成长加速', description: '持续上涨+量价齐升', logic: 'and', conditions: [
|
||||
{ field: 'ret20', operator: '>', value: 15 },
|
||||
{ field: 'up_streak', operator: '>=', value: 2 }
|
||||
]},
|
||||
'reversal': { name: '反转抄底', description: '超跌企稳+技术反转', logic: 'and', conditions: [
|
||||
{ field: 'ret20', operator: '<', value: -15 },
|
||||
{ field: 'rsi14', operator: '<', value: 30 },
|
||||
{ field: 'pct', operator: '>', value: 2 }
|
||||
]}
|
||||
};
|
||||
|
||||
_CURRENT_STRATEGY = presetStrategies[preset.id] || _CURRENT_STRATEGY;
|
||||
document.getElementById('sel-name').value = _CURRENT_STRATEGY.name;
|
||||
document.getElementById('sel-logic').value = _CURRENT_STRATEGY.logic;
|
||||
renderConditions();
|
||||
};
|
||||
|
||||
const saveStrategy = async () => {
|
||||
_CURRENT_STRATEGY.name = document.getElementById('sel-name').value.trim();
|
||||
if (!_CURRENT_STRATEGY.name) {
|
||||
alert('请输入策略名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const desc = prompt('策略描述(可选):');
|
||||
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/selector/strategies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: _CURRENT_STRATEGY.name,
|
||||
description: desc || '',
|
||||
strategy: _CURRENT_STRATEGY
|
||||
})
|
||||
}).then(x => x.json());
|
||||
|
||||
if (r.ok) {
|
||||
alert('保存成功!ID: ' + r.id);
|
||||
} else {
|
||||
alert('保存失败:' + r.msg);
|
||||
}
|
||||
} catch {
|
||||
alert('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const backtest = async () => {
|
||||
if (!_CURRENT_STRATEGY.conditions.length) {
|
||||
alert('请先添加条件');
|
||||
return;
|
||||
}
|
||||
|
||||
const days = prompt('回测天数(20-250):', '60');
|
||||
if (!days) return;
|
||||
|
||||
document.getElementById('sel-result').innerHTML = '<div class="loading">回测中,请稍候…</div>';
|
||||
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/selector/backtest?days=' + days, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strategy: _CURRENT_STRATEGY })
|
||||
}).then(x => x.json());
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('sel-result').innerHTML = r.msg || '回测失败';
|
||||
return;
|
||||
}
|
||||
|
||||
const s = r.summary;
|
||||
const card = (l, v, c) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr ${c || ''} num" style="font-size:18px">${v}</div></div>`;
|
||||
let html = `<div style="padding:12px;background:#0f1419;margin-bottom:12px;border-radius:4px">
|
||||
<b>回测结果</b>(${r.days}天,${s.total_days}个交易日)
|
||||
</div>`;
|
||||
html += `<div class="row c4" style="margin-bottom:12px">`;
|
||||
html += card('平均收益', sign(s.avg_return) + s.avg_return + '%', cls(s.avg_return));
|
||||
html += card('平均胜率', s.avg_win_rate + '%', s.avg_win_rate > 60 ? 'up' : '');
|
||||
html += card('最佳日', sign(s.best_day.avg_return) + s.best_day.avg_return + '%', 'up');
|
||||
html += card('最差日', sign(s.worst_day.avg_return) + s.worst_day.avg_return + '%', 'down');
|
||||
html += '</div>';
|
||||
|
||||
const rows = r.daily.slice(-20).map(d => [
|
||||
`<td>${d.date}</td>`,
|
||||
`<td class="num">${d.count}</td>`,
|
||||
`<td class="${cls(d.avg_return)} num">${sign(d.avg_return)}${fmt(d.avg_return)}%</td>`,
|
||||
`<td class="num">${d.win_rate}%</td>`,
|
||||
`<td class="${cls(d.max_return)} num">${sign(d.max_return)}${fmt(d.max_return)}%</td>`
|
||||
]);
|
||||
html += `<div style="padding:8px 0;color:var(--text-dim)">最近20个交易日明细(持有5日后收益)</div>`;
|
||||
html += rankTable(['日期', '命中数', '平均收益', '胜率', '最大收益'], rows);
|
||||
|
||||
document.getElementById('sel-result').innerHTML = html;
|
||||
} catch (e) {
|
||||
document.getElementById('sel-result').innerHTML = '回测失败:' + e;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('sel-add').onclick = addCondition;
|
||||
document.getElementById('sel-run').onclick = runSelector;
|
||||
document.getElementById('sel-preset').onclick = loadPreset;
|
||||
document.getElementById('sel-save').onclick = saveStrategy;
|
||||
document.getElementById('sel-backtest').onclick = backtest;
|
||||
|
||||
renderConditions();
|
||||
};
|
||||
|
||||
// 选股结果对比
|
||||
VIEWS['screen-cond'] = async function(view) {
|
||||
view.innerHTML = `<div class="panel"><div class="panel-head"><span class="bar"></span>选股结果对比 <span class="sub">今日 vs 昨日</span>
|
||||
<button id="cmp-run" class="btn-run" style="margin-left:auto">执行对比</button></div>
|
||||
<div class="panel-body">
|
||||
<div style="margin-bottom:12px">当前策略:<b id="cmp-strategy">(请先在「智能选股器」配置策略)</b></div>
|
||||
<div id="cmp-summary" class="row c4" style="margin-bottom:12px"></div>
|
||||
<div class="row c3">
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>新入选</div><div class="panel-body" style="padding:0"><div id="cmp-new"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>退出</div><div class="panel-body" style="padding:0"><div id="cmp-drop"></div></div></div>
|
||||
<div class="panel"><div class="panel-head"><span class="bar"></span>持续入选</div><div class="panel-body" style="padding:0"><div id="cmp-cont"></div></div></div>
|
||||
</div>
|
||||
</div></div>`;
|
||||
|
||||
document.getElementById('cmp-strategy').textContent = _CURRENT_STRATEGY.name || '未配置';
|
||||
|
||||
const compare = async () => {
|
||||
if (!_CURRENT_STRATEGY.conditions || !_CURRENT_STRATEGY.conditions.length) {
|
||||
alert('请先在「智能选股器」配置策略');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取最近两个交易日
|
||||
let dates;
|
||||
try {
|
||||
const r = await apiGet('/api/admin/status');
|
||||
if (!r.last_dates || !r.last_dates.quotes_daily) {
|
||||
alert('暂无数据');
|
||||
return;
|
||||
}
|
||||
const latest = r.last_dates.quotes_daily;
|
||||
const d1 = new Date(latest);
|
||||
d1.setDate(d1.getDate() - 1);
|
||||
dates = { date1: d1.toISOString().slice(0, 10), date2: latest };
|
||||
} catch {
|
||||
alert('获取日期失败');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('cmp-new').innerHTML = '<div class="loading">对比中…</div>';
|
||||
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/selector/compare', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strategy: _CURRENT_STRATEGY, ...dates })
|
||||
}).then(x => x.json());
|
||||
|
||||
if (!r.ok) {
|
||||
document.getElementById('cmp-new').innerHTML = r.msg;
|
||||
return;
|
||||
}
|
||||
|
||||
const card = (l, v) => `<div class="idx-card"><div class="nm">${l}</div><div class="pr num" style="font-size:20px">${v}</div></div>`;
|
||||
document.getElementById('cmp-summary').innerHTML =
|
||||
card('昨日命中', r.count1) + card('今日命中', r.count2) +
|
||||
card('新入选', r.new_in.count) + card('退出', r.dropped.count);
|
||||
|
||||
const makeTable = (stocks) => {
|
||||
if (!stocks.length) return '<div class="loading">无</div>';
|
||||
const rows = stocks.slice(0, 15).map(s => `<tr><td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
|
||||
<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td></tr>`).join('');
|
||||
return `<table class="grid-tbl"><tbody>${rows}</tbody></table>`;
|
||||
};
|
||||
|
||||
document.getElementById('cmp-new').innerHTML = makeTable(r.new_in.stocks);
|
||||
document.getElementById('cmp-drop').innerHTML = makeTable(r.dropped.stocks);
|
||||
document.getElementById('cmp-cont').innerHTML = makeTable(r.continued.stocks);
|
||||
} catch (e) {
|
||||
document.getElementById('cmp-new').innerHTML = '对比失败:' + e;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('cmp-run').onclick = compare;
|
||||
};
|
||||
165
功能扩展.md
Normal file
165
功能扩展.md
Normal file
@@ -0,0 +1,165 @@
|
||||
基于当前架构,建议以下功能扩展方向:
|
||||
|
||||
一、高价值功能扩展
|
||||
1. 盘中实时监控雷达 ⭐⭐⭐⭐⭐
|
||||
痛点:当前只有收盘后复盘,错过盘中异动机会
|
||||
|
||||
功能点:
|
||||
|
||||
快速拉升监控(5分钟涨幅 >3%)
|
||||
放量突破预警(量比 >3 且突破关键位)
|
||||
涨停打开/炸板追踪
|
||||
连板股实时追踪
|
||||
大单异动(单笔超百万)
|
||||
实现难度:中等(需要分钟线数据源)
|
||||
|
||||
2. 策略回测增强 ⭐⭐⭐⭐⭐
|
||||
痛点:当前只有简单 MA 交叉,无法验证复杂策略
|
||||
|
||||
功能点:
|
||||
|
||||
多因子组合回测(技术+基本面)
|
||||
仓位管理策略(金字塔加仓、止损止盈)
|
||||
参数优化网格搜索
|
||||
夏普比率/最大回撤/胜率等完整指标
|
||||
策略对比(并排显示多条净值曲线)
|
||||
交易明细导出(每笔买卖点、持仓天数、收益率)
|
||||
实现难度:中等
|
||||
|
||||
3. 板块轮动分析 ⭐⭐⭐⭐
|
||||
痛点:只有当日板块涨跌,看不出资金流向趋势
|
||||
|
||||
功能点:
|
||||
|
||||
板块强弱排名趋势(近5日/10日/20日)
|
||||
资金流向桑基图(从哪个板块流向哪个板块)
|
||||
板块生命周期判断(启动期/加速期/衰退期)
|
||||
龙头股自动识别(板块内涨幅+成交额排名)
|
||||
板块联动性分析(相关系数热力图)
|
||||
实现难度:中等
|
||||
|
||||
4. 智能选股增强 ⭐⭐⭐⭐
|
||||
痛点:当前选股策略固定,无法自定义
|
||||
|
||||
功能点:
|
||||
|
||||
可视化选股器(拖拽式多条件组合)
|
||||
选股结果回测验证(该选股规则历史表现)
|
||||
选股策略保存/分享
|
||||
条件预警(符合条件时自动推送)
|
||||
选股结果对比(今日 vs 昨日,哪些股票新入选/退出)
|
||||
实现难度:中高
|
||||
|
||||
5. 持仓归因分析深化 ⭐⭐⭐⭐
|
||||
痛点:当前只有简单盈亏,不知道赚钱/亏钱原因
|
||||
|
||||
功能点:
|
||||
|
||||
收益归因分解:选股能力 vs 择时能力 vs 运气成分
|
||||
持仓时长分析(短线/中线/长线各自胜率)
|
||||
买入理由有效性验证(按理由统计胜率)
|
||||
情绪标签相关性(贪婪/恐惧/理性,各自收益率)
|
||||
对标指数超额收益拆解
|
||||
实现难度:中等
|
||||
|
||||
二、差异化功能扩展
|
||||
6. AI 对话式分析 ⭐⭐⭐⭐⭐
|
||||
亮点:与大模型深度结合,打造对话式炒股助手
|
||||
|
||||
功能点:
|
||||
|
||||
自然语言选股:"帮我找近期突破且资金流入的科技股"
|
||||
持仓诊断对话:"我的持仓有什么风险?"
|
||||
策略建议:"当前市场适合什么策略?"
|
||||
实时问答:"某某股票为什么今天大跌?"
|
||||
上下文记忆(多轮对话,记住用户偏好)
|
||||
实现难度:中高(需要 Function Calling)
|
||||
|
||||
7. 社区情绪监控 ⭐⭐⭐⭐
|
||||
亮点:量化散户情绪,反向指标
|
||||
|
||||
功能点:
|
||||
|
||||
爬取东方财富/雪球/股吧热帖
|
||||
情绪指数(乐观/悲观比例)
|
||||
热议股票排行(讨论量激增预警)
|
||||
关键词云图(当前市场关注什么)
|
||||
情绪与股价相关性回测
|
||||
实现难度:中高(需要爬虫+NLP)
|
||||
|
||||
8. 事件驱动策略 ⭐⭐⭐⭐
|
||||
亮点:基于财经事件的量化交易
|
||||
|
||||
功能点:
|
||||
|
||||
财报发布前后统计规律
|
||||
限售解禁影响回测
|
||||
高管增持/减持跟踪
|
||||
行业政策事件库(光伏补贴、新能源政策等)
|
||||
事件驱动选股(财报超预期+高管增持)
|
||||
实现难度:中高
|
||||
|
||||
9. 模拟盘对战 ⭐⭐⭐
|
||||
亮点:用户间 PK,增加粘性
|
||||
|
||||
功能点:
|
||||
|
||||
创建模拟盘对战房间
|
||||
排行榜(收益率/夏普比率)
|
||||
查看高手持仓(匿名化)
|
||||
复盘对比(我为什么输给他)
|
||||
月度/季度冠军
|
||||
实现难度:中等
|
||||
|
||||
10. 财报深度解读 ⭐⭐⭐⭐
|
||||
痛点:技术分析为主,缺少基本面支持
|
||||
|
||||
功能点:
|
||||
|
||||
财报关键指标趋势(ROE/毛利率/营收增速)
|
||||
AI 财报摘要(一句话总结)
|
||||
同行对比(与行业均值对比)
|
||||
财报异常预警(存货激增、应收账款占比过高)
|
||||
财报发布日历提醒
|
||||
实现难度:中等
|
||||
|
||||
三、实用小功能
|
||||
11. 交易日历与提醒
|
||||
除权除息提醒
|
||||
解禁日期预警
|
||||
业绩预告/快报发布日历
|
||||
股东大会日期
|
||||
12. 成本计算器
|
||||
精确计算交易成本(印花税/佣金/过户费)
|
||||
补仓成本计算
|
||||
持仓成本线标注在 K 线图上
|
||||
13. 涨跌停分析
|
||||
连板股追踪器(今日几进几?)
|
||||
炸板率统计(某股涨停后次日表现)
|
||||
涨停敢死队排行
|
||||
14. 关联股票发现
|
||||
同一题材股票聚类
|
||||
供应链上下游关系图
|
||||
股东关联关系挖掘
|
||||
15. 数据导出与备份
|
||||
持仓数据导出 Excel
|
||||
交易记录导出
|
||||
选股结果导出
|
||||
数据库自动备份
|
||||
建议优先级排序(ROI 角度)
|
||||
排名 功能 投入成本 用户价值 差异化
|
||||
1 AI 对话式分析 高 极高 极高
|
||||
2 盘中实时监控雷达 中 极高 高
|
||||
3 策略回测增强 中 高 中
|
||||
4 板块轮动分析 中 高 中
|
||||
5 持仓归因分析深化 低 高 中
|
||||
快速见效(先做):
|
||||
|
||||
盘中实时监控雷达
|
||||
策略回测增强
|
||||
持仓归因分析深化
|
||||
长期竞争力(后做):
|
||||
|
||||
AI 对话式分析
|
||||
社区情绪监控
|
||||
事件驱动策略
|
||||
597
功能拓展/AI对话式分析使用说明.md
Normal file
597
功能拓展/AI对话式分析使用说明.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# AI对话式分析使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
AI对话式分析是Blackdata股票终端的终极功能,通过自然语言与AI助手对话,实现智能选股、持仓诊断、策略建议等功能,无需记忆复杂操作步骤。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 自然语言理解
|
||||
不需要点击菜单或填写表单,直接用人话描述需求:
|
||||
```
|
||||
❌ 传统方式:进入"选股引擎" → 选择字段 → 设置条件 → 执行
|
||||
✅ AI方式:"帮我找近期突破且资金流入的科技股"
|
||||
```
|
||||
|
||||
### 2. 上下文记忆
|
||||
AI会记住对话历史,支持多轮连续对话:
|
||||
```
|
||||
用户:帮我选股
|
||||
AI:[返回10只股票]
|
||||
用户:分析第1只
|
||||
AI:[分析第1只股票,无需再次指定代码]
|
||||
```
|
||||
|
||||
### 3. 智能功能调用
|
||||
AI自动识别意图并调用相应功能:
|
||||
- 选股功能
|
||||
- 持仓诊断
|
||||
- 策略建议
|
||||
- 个股分析
|
||||
- 板块分析
|
||||
|
||||
### 4. 多模态回复
|
||||
根据问题类型,返回不同格式的结果:
|
||||
- 文字解释
|
||||
- 数据表格
|
||||
- 操作建议
|
||||
- 风险提示
|
||||
|
||||
---
|
||||
|
||||
## 支持的问题类型
|
||||
|
||||
### 类型1:自然语言选股
|
||||
|
||||
**示例问题**:
|
||||
```
|
||||
✓ 帮我找近期突破的科技股
|
||||
✓ 推荐一些超跌反弹的股票
|
||||
✓ 有哪些量比大于2且上涨的股票
|
||||
✓ 找5日涨幅超过10%的股票
|
||||
✓ 成交额大于10亿的强势股
|
||||
```
|
||||
|
||||
**AI如何处理**:
|
||||
1. 识别为选股需求
|
||||
2. 提取选股条件:
|
||||
- "近期突破" → 5日涨幅 > 5%
|
||||
- "科技股" → 板块筛选(简化处理)
|
||||
- "资金流入" → 量比 > 1.5
|
||||
3. 调用选股引擎
|
||||
4. 返回前10只股票
|
||||
|
||||
**回复示例**:
|
||||
```
|
||||
为您找到 25 只股票,以下是前10只:
|
||||
|
||||
1. 中芯国际(688981)
|
||||
现价:52.30元 涨跌:+3.25% 5日:+12.8%
|
||||
量比:2.85 成交额:45.6亿
|
||||
|
||||
2. 北方华创(002371)
|
||||
现价:285.60元 涨跌:+2.10% 5日:+9.3%
|
||||
量比:2.12 成交额:38.2亿
|
||||
|
||||
...
|
||||
|
||||
💡 您可以继续问我:
|
||||
- 分析某只股票(如"分析第1只")
|
||||
- 回测这个策略
|
||||
- 看看其他板块
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 类型2:持仓诊断对话
|
||||
|
||||
**示例问题**:
|
||||
```
|
||||
✓ 我的持仓有什么风险?
|
||||
✓ 帮我看看持仓情况
|
||||
✓ 诊断一下我的组合
|
||||
✓ 有哪些股票在亏钱?
|
||||
✓ 我应该止损吗?
|
||||
```
|
||||
|
||||
**AI如何处理**:
|
||||
1. 识别为持仓诊断需求
|
||||
2. 读取当前持仓数据
|
||||
3. 分析风险点:
|
||||
- 浮亏比例
|
||||
- 持仓集中度
|
||||
- 历史胜率
|
||||
4. 给出诊断报告和建议
|
||||
|
||||
**回复示例**:
|
||||
```
|
||||
📊 持仓诊断报告
|
||||
|
||||
持仓数量:5 只
|
||||
持仓市值:125,680.00 元
|
||||
浮动盈亏:-3,250.00 元
|
||||
历史胜率:58%
|
||||
|
||||
⚠️ 风险提示:
|
||||
⚠️ 超过一半的持仓处于浮亏状态(3/5只)
|
||||
|
||||
📈 前5大持仓:
|
||||
1. 贵州茅台 +5.20% +6500元
|
||||
2. 中芯国际 -8.50% -4200元
|
||||
3. 宁德时代 -3.20% -2800元
|
||||
4. 比亚迪 +2.10% +1800元
|
||||
5. 隆基绿能 -12.30% -4550元
|
||||
|
||||
💡 建议:
|
||||
- 考虑止损浮亏较大的股票(如隆基绿能)
|
||||
- 增加持仓分散度
|
||||
- 定期复盘,总结经验
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 类型3:策略建议
|
||||
|
||||
**示例问题**:
|
||||
```
|
||||
✓ 当前市场适合什么策略?
|
||||
✓ 现在应该怎么操作?
|
||||
✓ 给我一些投资建议
|
||||
✓ 现在是买入还是观望的时机?
|
||||
✓ 市场风格是什么?
|
||||
```
|
||||
|
||||
**AI如何处理**:
|
||||
1. 识别为策略建议需求
|
||||
2. 获取板块轮动数据
|
||||
3. 分析市场强弱
|
||||
4. 给出操作建议
|
||||
|
||||
**回复示例**:
|
||||
```
|
||||
📋 当前市场策略建议
|
||||
|
||||
🔥 强势板块:
|
||||
- 半导体 +8.5%
|
||||
- 新能源 +6.2%
|
||||
- 人工智能 +5.8%
|
||||
|
||||
📉 弱势板块:
|
||||
- 地产 -3.2%
|
||||
- 银行 -1.8%
|
||||
- 煤炭 -1.5%
|
||||
|
||||
💡 策略建议:
|
||||
- 市场情绪较好,适合进攻型策略
|
||||
- 可关注强势板块的龙头股
|
||||
- 设置好止盈点,及时落袋为安
|
||||
|
||||
🎯 具体操作:
|
||||
- 可以问我"帮我找半导体的股票"
|
||||
- 或"分析新能源板块"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 类型4:个股分析
|
||||
|
||||
**示例问题**:
|
||||
```
|
||||
✓ 分析一下贵州茅台
|
||||
✓ 600519怎么样?
|
||||
✓ 帮我看看中芯国际
|
||||
✓ 这只股票能买吗?(需要先提到股票)
|
||||
✓ 分析第1只(在选股结果后)
|
||||
```
|
||||
|
||||
**AI如何处理**:
|
||||
1. 识别为个股分析需求
|
||||
2. 提取股票代码
|
||||
3. 调用AI诊断功能
|
||||
4. 返回综合评分和建议
|
||||
|
||||
**回复示例**:
|
||||
```
|
||||
📊 贵州茅台(600519)AI诊断
|
||||
|
||||
综合评分:78分
|
||||
预测方向:看多
|
||||
置信度:72%
|
||||
|
||||
📈 各维度评分:
|
||||
- 技术面:82分
|
||||
- 资金面:75分
|
||||
- 基本面:85分
|
||||
- 动量:68分
|
||||
- 情绪面:70分
|
||||
- 估值:65分
|
||||
|
||||
💬 该股技术面强势,均线多头排列,MACD金叉...
|
||||
|
||||
💡 完整分析请在「AI分析 → 个股诊断」页面查看
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 类型5:板块分析
|
||||
|
||||
**示例问题**:
|
||||
```
|
||||
✓ 半导体板块怎么样?
|
||||
✓ 分析一下新能源板块
|
||||
✓ 医药板块现在能买吗?
|
||||
✓ 科技股还能涨吗?
|
||||
```
|
||||
|
||||
**AI如何处理**:
|
||||
1. 识别为板块分析需求
|
||||
2. 提取板块名称
|
||||
3. 调用板块生命周期分析
|
||||
4. 返回阶段判断和龙头股
|
||||
|
||||
**回复示例**:
|
||||
```
|
||||
📊 半导体 板块分析
|
||||
|
||||
生命周期:加速期
|
||||
板块持续上涨且加速,成交活跃,主升浪阶段
|
||||
|
||||
📈 近期表现:
|
||||
- 5日涨幅:+8.50%
|
||||
- 20日涨幅:+23.70%
|
||||
- 成交额变化:+45.20%
|
||||
|
||||
🏆 龙头股:
|
||||
1. 中芯国际 +28.60%
|
||||
2. 北方华创 +25.30%
|
||||
3. 韦尔股份 +22.80%
|
||||
|
||||
💡 您可以继续问:
|
||||
- 帮我找半导体板块的股票
|
||||
- 半导体龙头股有哪些
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 类型6:闲聊与问候
|
||||
|
||||
**示例问题**:
|
||||
```
|
||||
✓ 你好
|
||||
✓ 你是谁?
|
||||
✓ 你能做什么?
|
||||
✓ 谢谢
|
||||
✓ 再见
|
||||
```
|
||||
|
||||
**AI回复**:
|
||||
友好的问候和功能介绍。
|
||||
|
||||
---
|
||||
|
||||
## 使用技巧
|
||||
|
||||
### 技巧1:渐进式提问
|
||||
|
||||
不要一次性问太复杂的问题,可以分步提问:
|
||||
|
||||
```
|
||||
❌ 不好的提问:
|
||||
"帮我找5日涨幅大于10%且量比大于2且RSI小于80的科技股,然后回测这个策略,再给我策略建议"
|
||||
|
||||
✅ 好的提问:
|
||||
用户:帮我找5日涨幅大于10%的科技股
|
||||
AI:[返回结果]
|
||||
用户:量比也要大于2
|
||||
AI:[筛选后返回]
|
||||
用户:回测一下这个策略
|
||||
AI:[回测结果]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 技巧2:利用上下文
|
||||
|
||||
AI会记住对话历史,善用这个特性:
|
||||
|
||||
```
|
||||
用户:帮我选股
|
||||
AI:[返回10只股票]
|
||||
|
||||
用户:分析第1只
|
||||
AI:[分析第1只股票]
|
||||
|
||||
用户:这只股票风险大吗?
|
||||
AI:[针对该股票回答风险]
|
||||
|
||||
用户:给我推荐其他的
|
||||
AI:[推荐选股结果中的其他股票]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 技巧3:明确需求
|
||||
|
||||
问题越明确,回答越精准:
|
||||
|
||||
```
|
||||
❌ 模糊:"有什么好股票?"
|
||||
✅ 明确:"帮我找近5日涨幅超过10%且量比大于2的股票"
|
||||
|
||||
❌ 模糊:"市场怎么样?"
|
||||
✅ 明确:"当前半导体板块处于什么阶段?"
|
||||
|
||||
❌ 模糊:"我该买什么?"
|
||||
✅ 明确:"根据我的持仓情况,推荐一些分散风险的股票"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 技巧4:组合使用功能
|
||||
|
||||
将多个功能串联起来:
|
||||
|
||||
```
|
||||
流程示例:
|
||||
|
||||
1. 用户:当前市场适合什么策略?
|
||||
AI:[分析市场,建议关注半导体]
|
||||
|
||||
2. 用户:帮我找半导体板块的股票
|
||||
AI:[返回10只股票]
|
||||
|
||||
3. 用户:分析第1只
|
||||
AI:[详细分析]
|
||||
|
||||
4. 用户:回测这个选股策略
|
||||
AI:[回测结果]
|
||||
|
||||
5. 用户:我的持仓有什么风险?
|
||||
AI:[诊断持仓]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 步骤1:配置大模型(必需)
|
||||
|
||||
在 `backend/.env` 中配置:
|
||||
|
||||
```env
|
||||
LLM_API_KEY=sk-xxxxx
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-3.5-turbo
|
||||
```
|
||||
|
||||
**支持的大模型**:
|
||||
- OpenAI(GPT-3.5/GPT-4)
|
||||
- 国内大模型(通义千问、文心一言、讯飞星火等,通过兼容接口)
|
||||
- 本地部署模型(Ollama、LM Studio等)
|
||||
|
||||
---
|
||||
|
||||
### 步骤2:启动服务
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤3:开始对话
|
||||
|
||||
1. 打开浏览器访问 `http://127.0.0.1:8000`
|
||||
2. 进入"AI分析" → "AI对话助手"
|
||||
3. 在输入框输入问题,按回车发送
|
||||
|
||||
**第一次对话建议**:
|
||||
```
|
||||
你好,请介绍一下你的功能
|
||||
```
|
||||
|
||||
AI会详细介绍可以做什么。
|
||||
|
||||
---
|
||||
|
||||
## 实战案例
|
||||
|
||||
### 案例1:从零开始选股
|
||||
|
||||
**对话记录**:
|
||||
|
||||
```
|
||||
用户:你好
|
||||
|
||||
AI:👋 你好!我是Blackdata AI助手。
|
||||
|
||||
我可以帮你:
|
||||
• 自然语言选股
|
||||
• 诊断持仓风险
|
||||
• 给出策略建议
|
||||
• 分析个股和板块
|
||||
|
||||
有什么可以帮你的吗?
|
||||
|
||||
---
|
||||
|
||||
用户:当前市场适合什么策略?
|
||||
|
||||
AI:📋 当前市场策略建议
|
||||
|
||||
🔥 强势板块:
|
||||
- 半导体 +8.5%
|
||||
- 新能源 +6.2%
|
||||
- 人工智能 +5.8%
|
||||
|
||||
...
|
||||
|
||||
💡 策略建议:
|
||||
- 市场情绪较好,适合进攻型策略
|
||||
- 可关注强势板块的龙头股
|
||||
|
||||
---
|
||||
|
||||
用户:帮我找半导体板块涨幅超过5%的股票
|
||||
|
||||
AI:为您找到 18 只股票,以下是前10只:
|
||||
|
||||
1. 中芯国际(688981)
|
||||
现价:52.30元 涨跌:+3.25% 5日:+12.8%
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
用户:分析第1只
|
||||
|
||||
AI:📊 中芯国际(688981)AI诊断
|
||||
|
||||
综合评分:82分
|
||||
预测方向:看多
|
||||
置信度:75%
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
用户:谢谢
|
||||
|
||||
AI:不客气!有其他问题随时问我 😊
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 案例2:持仓诊断与优化
|
||||
|
||||
**对话记录**:
|
||||
|
||||
```
|
||||
用户:我的持仓有什么问题?
|
||||
|
||||
AI:📊 持仓诊断报告
|
||||
|
||||
持仓数量:5 只
|
||||
浮动盈亏:-3,250.00 元
|
||||
|
||||
⚠️ 风险提示:
|
||||
⚠️ 超过一半的持仓处于浮亏状态(3/5只)
|
||||
|
||||
📈 前5大持仓:
|
||||
1. 贵州茅台 +5.20%
|
||||
2. 中芯国际 -8.50%
|
||||
3. 隆基绿能 -12.30%
|
||||
...
|
||||
|
||||
💡 建议:
|
||||
- 考虑止损浮亏较大的股票
|
||||
|
||||
---
|
||||
|
||||
用户:隆基绿能为什么跌这么多?
|
||||
|
||||
AI:[分析隆基绿能]
|
||||
|
||||
---
|
||||
|
||||
用户:我应该止损吗?
|
||||
|
||||
AI:根据您的持仓情况:
|
||||
|
||||
隆基绿能浮亏-12.30%,建议:
|
||||
1. 如果跌破-15%,坚决止损
|
||||
2. 检查基本面是否恶化
|
||||
3. 设置止损线,严格执行
|
||||
|
||||
注意:这不构成投资建议,请根据自己的风险承受能力决策。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/chat
|
||||
发送消息
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"session_id": "session_123456",
|
||||
"message": "帮我找近期突破的科技股"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"type": "select_stocks",
|
||||
"text": "为您找到 25 只股票...",
|
||||
"data": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/chat/{session_id}
|
||||
清空会话
|
||||
|
||||
### GET /api/chat/{session_id}/history
|
||||
获取会话历史
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **大模型依赖**:
|
||||
- 必须配置 LLM_API_KEY
|
||||
- 未配置时会提示降级使用其他功能
|
||||
|
||||
2. **API费用**:
|
||||
- 每次对话会调用大模型API
|
||||
- 建议使用便宜的模型(如GPT-3.5-turbo)
|
||||
- 控制对话长度以节省费用
|
||||
|
||||
3. **数据隐私**:
|
||||
- 对话内容会发送到大模型服务商
|
||||
- 不要在对话中透露敏感信息(密码、账号等)
|
||||
|
||||
4. **准确性**:
|
||||
- AI回答基于当前数据,但不保证100%准确
|
||||
- 投资决策请结合自己的判断
|
||||
- 不构成投资建议
|
||||
|
||||
5. **会话管理**:
|
||||
- 会话保存在内存中,重启后清空
|
||||
- 对话历史保留最近20轮
|
||||
- 可手动清空会话重新开始
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 语音输入/输出
|
||||
- [ ] 更多Function Calling(如直接下单、设置预警)
|
||||
- [ ] 会话持久化(保存到数据库)
|
||||
- [ ] 多用户支持
|
||||
- [ ] 对话导出(PDF报告)
|
||||
- [ ] 实时行情推送("茅台涨了多少")
|
||||
- [ ] 图表生成(直接生成K线图)
|
||||
- [ ] 策略自动执行
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
AI对话式分析是Blackdata股票终端的**终极形态**,将所有功能融合到自然语言交互中,让投资分析像聊天一样简单。
|
||||
|
||||
**核心价值**:
|
||||
- **零门槛**:不需要学习操作步骤
|
||||
- **高效率**:一句话完成复杂操作
|
||||
- **智能化**:AI自动识别意图并调用功能
|
||||
- **个性化**:记住对话历史,越用越懂你
|
||||
|
||||
**适合人群**:
|
||||
- 所有投资者(新手尤其友好)
|
||||
- 喜欢自然交互的用户
|
||||
- 希望快速获取信息的用户
|
||||
|
||||
**开始使用**:
|
||||
配置好大模型 → 进入"AI对话助手" → 开始聊天 → 享受智能投资体验!
|
||||
504
功能拓展/事件驱动策略使用说明.md
Normal file
504
功能拓展/事件驱动策略使用说明.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# 事件驱动策略使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
事件驱动策略是一种基于公司或行业重大事件进行投资决策的量化策略。通过统计分析历史事件对股价的影响,找出规律并应用于实战。
|
||||
|
||||
**核心理念**:重大事件往往改变市场预期,带来股价波动,提前布局或及时应对可获取超额收益。
|
||||
|
||||
---
|
||||
|
||||
## 五大事件类型
|
||||
|
||||
### 1. 财报发布(Earnings)
|
||||
|
||||
**事件说明**:
|
||||
上市公司定期发布财报(季报、半年报、年报),业绩是否符合预期直接影响股价。
|
||||
|
||||
**事件分类**:
|
||||
- **业绩超预期**(Positive):实际业绩好于市场预期
|
||||
- **业绩不及预期**(Negative):实际业绩低于市场预期
|
||||
- **业绩符合预期**(Neutral):实际业绩与预期一致
|
||||
|
||||
**统计分析**:
|
||||
系统统计历史上所有财报事件,分析:
|
||||
- 财报前N天的股价表现
|
||||
- 财报后N天的股价表现
|
||||
- 后续胜率(上涨概率)
|
||||
|
||||
**典型规律**:
|
||||
|
||||
| 业绩类型 | 财报前表现 | 财报后表现 | 胜率 |
|
||||
|----------|------------|------------|------|
|
||||
| 超预期 | 可能提前上涨 | 继续上涨 | 70%+ |
|
||||
| 不及预期 | 可能高位 | 大幅下跌 | 30%- |
|
||||
| 符合预期 | 平稳 | 小幅波动 | 50% |
|
||||
|
||||
**投资策略**:
|
||||
|
||||
**策略1:财报发布后介入**
|
||||
```
|
||||
条件:
|
||||
- 业绩超预期
|
||||
- 财报发布后1-3天内
|
||||
- 股价尚未大涨(涨幅 < 10%)
|
||||
|
||||
操作:
|
||||
- 买入
|
||||
- 止盈:+15%
|
||||
- 止损:-5%
|
||||
- 持仓周期:5-10天
|
||||
|
||||
历史胜率:68%
|
||||
平均收益:+8.5%
|
||||
```
|
||||
|
||||
**策略2:财报前规避**
|
||||
```
|
||||
如果持有股票,财报发布前:
|
||||
- 业绩确定性高 → 继续持有
|
||||
- 业绩不确定 → 减仓或清仓
|
||||
- 避免赌业绩
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 高管增减持(Insider Trading)
|
||||
|
||||
**事件说明**:
|
||||
公司高管、大股东增持或减持公司股票,往往透露内部信息。
|
||||
|
||||
**信号解读**:
|
||||
|
||||
**增持(Positive)**:
|
||||
- **含义**:高管看好公司未来
|
||||
- **信号强度**:⭐⭐⭐⭐
|
||||
- **投资价值**:跟随买入
|
||||
|
||||
**减持(Negative)**:
|
||||
- **含义**:高管套现离场
|
||||
- **信号强度**:⭐⭐⭐
|
||||
- **投资价值**:警惕,考虑卖出
|
||||
|
||||
**关键指标**:
|
||||
|
||||
1. **增持金额**
|
||||
- < 1000万:一般信号
|
||||
- 1000万 - 5000万:强信号
|
||||
- > 5000万:极强信号
|
||||
|
||||
2. **增持主体**
|
||||
- 董事长/总经理:信号最强
|
||||
- 高管团队集体增持:信号极强
|
||||
- 普通股东:信号较弱
|
||||
|
||||
3. **增持时机**
|
||||
- 股价低位增持:强看好
|
||||
- 股价高位增持:一般
|
||||
- 连续增持:信号持续
|
||||
|
||||
**投资策略**:
|
||||
|
||||
**策略1:跟随增持**
|
||||
```
|
||||
筛选条件:
|
||||
- 高管增持金额 > 1000万
|
||||
- 增持时股价处于相对低位(60日分位 < 30%)
|
||||
- 公司基本面良好
|
||||
|
||||
操作:
|
||||
- 增持公告后1-3天内买入
|
||||
- 持仓周期:1-3个月
|
||||
- 止损:-10%
|
||||
|
||||
历史胜率:65%
|
||||
平均收益:+12.3%
|
||||
```
|
||||
|
||||
**策略2:规避减持**
|
||||
```
|
||||
警惕信号:
|
||||
- 高管大比例减持(> 持股10%)
|
||||
- 多位高管同时减持
|
||||
- 减持后继续公告减持计划
|
||||
|
||||
操作:
|
||||
- 如有持仓,考虑卖出
|
||||
- 避免新进入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 限售解禁(Share Unlock)
|
||||
|
||||
**事件说明**:
|
||||
限售股份到期解禁,可以流通交易,通常带来抛压。
|
||||
|
||||
**影响分析**:
|
||||
|
||||
**解禁规模**:
|
||||
- **小规模**(< 5%流通股):影响较小
|
||||
- **中规模**(5-20%):有一定抛压
|
||||
- **大规模**(> 20%):抛压明显
|
||||
|
||||
**解禁对象**:
|
||||
- **创投/PE**:减持意愿强
|
||||
- **产业资本**:减持意愿一般
|
||||
- **核心高管**:减持意愿弱
|
||||
|
||||
**历史规律**:
|
||||
|
||||
```
|
||||
统计结果(近90天,30个解禁事件):
|
||||
- 平均影响:-3.8%
|
||||
- 下跌比例:72%
|
||||
- 最大跌幅:-15.6%
|
||||
```
|
||||
|
||||
**投资策略**:
|
||||
|
||||
**策略1:解禁前规避**
|
||||
```
|
||||
条件:
|
||||
- 解禁规模 > 10%流通股
|
||||
- 解禁对象为创投/PE
|
||||
- 股价处于高位
|
||||
|
||||
操作:
|
||||
- 解禁前10天减仓或清仓
|
||||
- 等待解禁后企稳再考虑
|
||||
```
|
||||
|
||||
**策略2:解禁后抄底**
|
||||
```
|
||||
条件:
|
||||
- 解禁后股价大跌(> 15%)
|
||||
- 公司基本面良好
|
||||
- 估值合理
|
||||
|
||||
操作:
|
||||
- 解禁后20-30天,股价企稳时介入
|
||||
- 长期持有
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 行业政策(Policy)
|
||||
|
||||
**事件说明**:
|
||||
国家或地方政府发布影响特定行业的政策,如补贴、扶持、监管、限制等。
|
||||
|
||||
**政策类型**:
|
||||
|
||||
| 类型 | 说明 | 影响 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **补贴(Subsidy)** | 财政补贴 | 利好 | 新能源汽车补贴 |
|
||||
| **扶持(Support)** | 产业支持 | 利好 | 芯片产业扶持政策 |
|
||||
| **监管(Regulation)** | 行业规范 | 利空/中性 | 教培行业"双减" |
|
||||
| **限制(Restriction)** | 行业限制 | 利空 | 地产调控 |
|
||||
|
||||
**政策事件库**(示例):
|
||||
|
||||
```
|
||||
1. 新能源汽车补贴延续(2023.6)
|
||||
- 板块:新能源
|
||||
- 类型:补贴
|
||||
- 影响:利好
|
||||
- 涉及股票:比亚迪、宁德时代等
|
||||
|
||||
2. 芯片产业扶持政策出台(2023.8)
|
||||
- 板块:半导体
|
||||
- 类型:扶持
|
||||
- 影响:利好
|
||||
- 涉及股票:中芯国际、北方华创等
|
||||
|
||||
3. 光伏补贴退坡(2023.9)
|
||||
- 板块:光伏
|
||||
- 类型:补贴退坡
|
||||
- 影响:利空
|
||||
- 涉及股票:隆基绿能、通威股份等
|
||||
```
|
||||
|
||||
**投资策略**:
|
||||
|
||||
**策略1:政策利好提前布局**
|
||||
```
|
||||
流程:
|
||||
1. 关注政策动向(两会、部委文件)
|
||||
2. 政策预期阶段买入
|
||||
3. 政策正式发布前后卖出
|
||||
|
||||
案例:
|
||||
"碳中和"政策预期(2020.9)
|
||||
→ 布局新能源板块
|
||||
→ 政策正式发布后(2020.12)板块暴涨
|
||||
→ 提前布局获利30%+
|
||||
```
|
||||
|
||||
**策略2:政策利空规避**
|
||||
```
|
||||
原则:
|
||||
- 监管政策出台前,如有传闻,先减仓
|
||||
- 政策落地后,如超预期利空,坚决清仓
|
||||
- 等待政策冲击消化后再观察
|
||||
|
||||
案例:
|
||||
教培行业"双减"政策(2021.7)
|
||||
→ 政策传闻阶段减仓
|
||||
→ 政策正式落地,板块暴跌
|
||||
→ 成功规避风险
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 其他事件
|
||||
|
||||
**分红派息(Dividend)**:
|
||||
- 高分红股票吸引长期资金
|
||||
- 分红前后有"填权"行情
|
||||
|
||||
**股权激励**:
|
||||
- 激励计划公告往往利好
|
||||
- 解锁条件设定的业绩目标是参考
|
||||
|
||||
**重大合同**:
|
||||
- 大额订单、重大项目中标
|
||||
- 短期利好,关注持续性
|
||||
|
||||
**并购重组**:
|
||||
- 停牌前有异动
|
||||
- 复牌后分化(成功或失败)
|
||||
|
||||
---
|
||||
|
||||
## 事件驱动选股
|
||||
|
||||
**功能说明**:
|
||||
根据多个事件条件组合选股,找到同时符合多个利好事件的股票。
|
||||
|
||||
**选股逻辑**:
|
||||
```
|
||||
事件评分 = 事件数量 × 事件强度
|
||||
```
|
||||
|
||||
**示例配置**:
|
||||
|
||||
**配置1:稳健型**
|
||||
```
|
||||
事件类型:
|
||||
✓ 业绩超预期
|
||||
✓ 高管增持
|
||||
✓ 分红派息
|
||||
|
||||
时间范围:30天
|
||||
|
||||
筛选结果:
|
||||
同时具备以上事件的股票,基本面和管理层信心都很强
|
||||
```
|
||||
|
||||
**配置2:激进型**
|
||||
```
|
||||
事件类型:
|
||||
✓ 业绩超预期
|
||||
✓ 高管增持
|
||||
|
||||
时间范围:7天
|
||||
|
||||
筛选结果:
|
||||
近期多重利好叠加的股票,短期爆发力强
|
||||
```
|
||||
|
||||
**使用步骤**:
|
||||
1. 进入"事件驱动" → "事件选股"
|
||||
2. 勾选事件类型(可多选)
|
||||
3. 选择时间范围
|
||||
4. 点击"开始选股"
|
||||
5. 查看选股结果
|
||||
|
||||
---
|
||||
|
||||
## 实战案例
|
||||
|
||||
### 案例1:财报超预期+高管增持(双重利好)
|
||||
|
||||
**股票**:某科技股(代码隐藏)
|
||||
|
||||
**事件**:
|
||||
```
|
||||
2023.10.25:发布三季报,业绩超预期30%
|
||||
2023.10.28:董事长增持2000万元
|
||||
```
|
||||
|
||||
**操作**:
|
||||
```
|
||||
2023.10.29:买入
|
||||
买入理由:
|
||||
- 业绩超预期,基本面改善
|
||||
- 高管增持,内部看好
|
||||
- 双重利好叠加
|
||||
|
||||
持仓周期:30天
|
||||
卖出日期:2023.11.28
|
||||
收益率:+18.5%
|
||||
```
|
||||
|
||||
**复盘**:
|
||||
事件驱动策略成功,双重利好推动股价上涨。
|
||||
|
||||
---
|
||||
|
||||
### 案例2:限售解禁规避(成功避险)
|
||||
|
||||
**股票**:某消费股
|
||||
|
||||
**事件**:
|
||||
```
|
||||
2023.9.15:公告10天后解禁,规模占流通股25%
|
||||
```
|
||||
|
||||
**操作**:
|
||||
```
|
||||
2023.9.16:卖出全部持仓
|
||||
卖出理由:
|
||||
- 解禁规模大(25%)
|
||||
- 解禁对象为创投机构(减持意愿强)
|
||||
- 规避抛压
|
||||
|
||||
实际走势:
|
||||
解禁后10天,股价下跌12%
|
||||
成功规避风险
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 案例3:政策利好提前布局
|
||||
|
||||
**板块**:人工智能
|
||||
|
||||
**事件**:
|
||||
```
|
||||
2023.7:多个部委密集调研AI企业
|
||||
2023.8.10:AI产业发展规划预期升温
|
||||
```
|
||||
|
||||
**操作**:
|
||||
```
|
||||
2023.8.12:提前布局AI板块龙头
|
||||
买入理由:
|
||||
- 政策预期强烈
|
||||
- 产业趋势向好
|
||||
- 估值合理
|
||||
|
||||
2023.8.25:政策正式发布
|
||||
板块当日平均涨幅:+8%
|
||||
持仓30天收益:+25%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/events/seed
|
||||
生成示例事件数据
|
||||
|
||||
### GET /api/events/earnings/pattern
|
||||
财报发布前后统计规律
|
||||
|
||||
**参数**:
|
||||
- `days_before`:财报前N天(1-10)
|
||||
- `days_after`:财报后N天(5-30)
|
||||
|
||||
### GET /api/events/insider
|
||||
高管增减持跟踪
|
||||
|
||||
**参数**:
|
||||
- `code`:股票代码(可选)
|
||||
- `days`:统计天数(30-365)
|
||||
|
||||
### GET /api/events/unlock
|
||||
限售解禁影响分析
|
||||
|
||||
**参数**:
|
||||
- `days`:统计天数(30-180)
|
||||
|
||||
### GET /api/events/policy
|
||||
行业政策事件
|
||||
|
||||
**参数**:
|
||||
- `sector`:板块名称(可选)
|
||||
- `days`:统计天数(30-365)
|
||||
|
||||
### POST /api/events/selector
|
||||
事件驱动选股
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"event_types": ["earnings_positive", "insider_increase"],
|
||||
"days": 30
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据来源**:
|
||||
- 当前版本使用模拟数据演示
|
||||
- 实际生产需要接入东方财富、巨潮资讯等数据源
|
||||
|
||||
2. **事件滞后性**:
|
||||
- 公告发布到系统采集有延迟
|
||||
- 建议盘后统一采集
|
||||
|
||||
3. **历史规律不代表未来**:
|
||||
- 统计规律是概率,不是确定性
|
||||
- 需结合基本面和技术面综合判断
|
||||
|
||||
4. **事件真实性**:
|
||||
- 警惕虚假信息
|
||||
- 以官方公告为准
|
||||
|
||||
5. **风险控制**:
|
||||
- 事件驱动策略也有失败概率
|
||||
- 必须设置止损
|
||||
- 控制单票仓位
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 接入真实事件数据源
|
||||
- [ ] 增加更多事件类型(股权激励、重大合同等)
|
||||
- [ ] 事件情绪分析(市场反应强度)
|
||||
- [ ] 事件关联分析(产业链联动)
|
||||
- [ ] 事件预警推送
|
||||
- [ ] 事件驱动策略自动回测
|
||||
- [ ] 事件日历(未来事件预告)
|
||||
- [ ] 事件影响持续时间分析
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
事件驱动策略是一种**基于基本面信息的量化策略**,通过统计历史规律,捕捉事件带来的投资机会。
|
||||
|
||||
**核心优势**:
|
||||
- ✅ 有明确的事件触发点
|
||||
- ✅ 历史规律可统计验证
|
||||
- ✅ 逻辑清晰,易于执行
|
||||
- ✅ 可与技术分析结合
|
||||
|
||||
**适合人群**:
|
||||
- 基本面投资者
|
||||
- 中长期投资者
|
||||
- 追求确定性的投资者
|
||||
|
||||
**使用建议**:
|
||||
1. 先统计历史规律,验证有效性
|
||||
2. 结合基本面和技术面综合判断
|
||||
3. 设置止损,控制风险
|
||||
4. 跟踪事件后的股价表现,持续优化策略
|
||||
|
||||
**开始使用**:
|
||||
进入"事件驱动" → 点击"生成示例数据" → 查看各类事件分析 → 尝试事件选股!
|
||||
526
功能拓展/持仓归因分析深化使用说明.md
Normal file
526
功能拓展/持仓归因分析深化使用说明.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# 持仓归因分析深化使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
持仓归因分析深化模块帮助投资者深入理解盈亏来源,通过多维度分析找出投资中的优势和不足,从而改进投资策略。
|
||||
|
||||
## 五大核心分析
|
||||
|
||||
### 1. 选股能力 vs 择时能力
|
||||
|
||||
**功能说明**:
|
||||
分解投资收益的两大来源,判断是"选对了股票"还是"买卖点把握好"。
|
||||
|
||||
**指标定义**:
|
||||
|
||||
#### 选股能力
|
||||
- **计算方法**:买入后股票的整体涨幅(持有期间市场表现)
|
||||
- **正值**:选对了股票(股票整体上涨)
|
||||
- **负值**:选错了股票(股票整体下跌)
|
||||
- **示例**:
|
||||
```
|
||||
选股能力 +15%
|
||||
→ 买入的股票在持有期间平均上涨了15%
|
||||
→ 说明选股眼光不错
|
||||
```
|
||||
|
||||
#### 择时能力
|
||||
- **计算方法**:买卖价格与理想价格(最低/最高)的接近度
|
||||
- **评分**:100分满分,50分为平均水平
|
||||
- **解读**:
|
||||
- 80-100分:优秀,买在低点、卖在高点
|
||||
- 60-80分:良好
|
||||
- 40-60分:一般
|
||||
- <40分:较差,追高杀跌
|
||||
- **示例**:
|
||||
```
|
||||
择时能力 72分
|
||||
→ 买卖点把握较好
|
||||
→ 平均买入价接近期间低点,卖出价接近高点
|
||||
```
|
||||
|
||||
**综合解读**:
|
||||
|
||||
| 选股 | 择时 | 解读 | 改进建议 |
|
||||
|------|------|------|----------|
|
||||
| 正 | 高(>60) | 完美组合 | 继续保持 |
|
||||
| 正 | 低(<40) | 选股对但买卖点差 | 学习技术分析,改进买卖时机 |
|
||||
| 负 | 高(>60) | 股票选错了 | 改进选股逻辑,加强基本面研究 |
|
||||
| 负 | 低(<40) | 双重失误 | 系统性反思,暂停交易 |
|
||||
|
||||
**实战案例**:
|
||||
```
|
||||
用户A:
|
||||
选股能力:+18.5%
|
||||
择时能力:45分
|
||||
|
||||
分析:
|
||||
- 选股很好,买入的股票都大涨
|
||||
- 但买卖点把握一般,可能追高或过早卖出
|
||||
|
||||
建议:
|
||||
- 保持选股策略
|
||||
- 学习等待回调买入
|
||||
- 设置合理的止盈点(如趋势未破不卖)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 持仓时长分析
|
||||
|
||||
**功能说明**:
|
||||
按持仓周期统计胜率和收益,找出最适合自己的持仓周期。
|
||||
|
||||
**周期分类**:
|
||||
|
||||
| 周期 | 定义 | 特点 |
|
||||
|------|------|------|
|
||||
| **短线** | ≤5天 | 快进快出,需要盯盘 |
|
||||
| **中线** | 6-30天 | 波段操作,适合上班族 |
|
||||
| **长线** | >30天 | 价值投资,考验耐心 |
|
||||
|
||||
**分析维度**:
|
||||
- 交易次数
|
||||
- 胜率
|
||||
- 平均收益
|
||||
- 平均持仓天数
|
||||
- 总盈亏
|
||||
|
||||
**使用场景**:
|
||||
|
||||
#### 场景1:短线高手
|
||||
```
|
||||
短线(≤5天):
|
||||
- 交易次数:50次
|
||||
- 胜率:68%
|
||||
- 平均收益:+3.2%
|
||||
|
||||
中线(6-30天):
|
||||
- 交易次数:10次
|
||||
- 胜率:40%
|
||||
- 平均收益:-1.5%
|
||||
|
||||
结论:擅长短线,建议专注短线交易
|
||||
```
|
||||
|
||||
#### 场景2:长线为王
|
||||
```
|
||||
短线(≤5天):
|
||||
- 胜率:45%
|
||||
- 平均收益:+0.8%
|
||||
|
||||
长线(>30天):
|
||||
- 胜率:75%
|
||||
- 平均收益:+15.6%
|
||||
|
||||
结论:长线胜率和收益都更高,建议降低交易频率
|
||||
```
|
||||
|
||||
**策略建议**:
|
||||
系统会自动推荐表现最好的持仓周期:
|
||||
```
|
||||
"建议重点关注中线(6-30天),该周期胜率和收益表现最佳"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 买入理由有效性验证
|
||||
|
||||
**功能说明**:
|
||||
统计每个买入理由的胜率和收益,验证哪些理由是有效的。
|
||||
|
||||
**理由示例**:
|
||||
- 技术突破
|
||||
- 基本面改善
|
||||
- 板块轮动
|
||||
- 超跌反弹
|
||||
- 消息驱动
|
||||
- 跟风买入
|
||||
|
||||
**有效性判断**:
|
||||
- **有效**:胜率 > 50%
|
||||
- **无效**:胜率 ≤ 50%
|
||||
|
||||
**实战案例**:
|
||||
|
||||
```
|
||||
理由统计:
|
||||
|
||||
1. 突破买入
|
||||
- 次数:15次
|
||||
- 胜率:73% ✓ 有效
|
||||
- 平均收益:+6.8%
|
||||
- 总盈亏:+10200元
|
||||
|
||||
2. 超跌反弹
|
||||
- 次数:8次
|
||||
- 胜率:62% ✓ 有效
|
||||
- 平均收益:+4.2%
|
||||
- 总盈亏:+3360元
|
||||
|
||||
3. 跟风买入
|
||||
- 次数:12次
|
||||
- 胜率:33% ✗ 无效
|
||||
- 平均收益:-2.5%
|
||||
- 总盈亏:-3000元
|
||||
|
||||
结论:
|
||||
- "突破买入"和"超跌反弹"是有效策略,应继续使用
|
||||
- "跟风买入"胜率低,应避免
|
||||
```
|
||||
|
||||
**改进方向**:
|
||||
1. **放大有效理由**:增加"突破买入"的频率和仓位
|
||||
2. **避免无效理由**:彻底放弃"跟风买入"
|
||||
3. **优化理由**:将"跟风"改为"等待回调确认"
|
||||
|
||||
---
|
||||
|
||||
### 4. 情绪标签相关性分析
|
||||
|
||||
**功能说明**:
|
||||
分析不同情绪状态下的交易表现,识别情绪对收益的影响。
|
||||
|
||||
**情绪标签**:
|
||||
- **理性**:冷静分析后的决策
|
||||
- **贪婪**:追涨、加仓冲动
|
||||
- **恐惧**:恐慌性卖出
|
||||
- **纪律**:严格执行计划
|
||||
- **冲动**:未经思考的操作
|
||||
|
||||
**实战案例**:
|
||||
|
||||
```
|
||||
情绪统计:
|
||||
|
||||
1. 理性
|
||||
- 次数:20次
|
||||
- 胜率:70%
|
||||
- 平均收益:+5.8%
|
||||
- 总盈亏:+11600元
|
||||
|
||||
2. 纪律
|
||||
- 次数:15次
|
||||
- 胜率:67%
|
||||
- 平均收益:+4.9%
|
||||
- 总盈亏:+7350元
|
||||
|
||||
3. 贪婪
|
||||
- 次数:10次
|
||||
- 胜率:30%
|
||||
- 平均收益:-3.2%
|
||||
- 总盈亏:-3200元
|
||||
|
||||
4. 恐惧
|
||||
- 次数:5次
|
||||
- 胜率:20%
|
||||
- 平均收益:-5.6%
|
||||
- 总盈亏:-2800元
|
||||
```
|
||||
|
||||
**情绪建议示例**:
|
||||
```
|
||||
最佳情绪状态:理性(胜率70%,平均收益5.8%)
|
||||
最差情绪状态:恐惧(胜率20%,平均收益-5.6%)
|
||||
|
||||
建议:
|
||||
- 保持理性和纪律,避免在贪婪或恐慌时做决策
|
||||
- 设置交易计划,严格执行
|
||||
- 市场波动时暂停操作,冷静后再交易
|
||||
```
|
||||
|
||||
**情绪管理技巧**:
|
||||
1. **交易前**:写下买入理由和预期目标
|
||||
2. **持仓中**:避免频繁看盘
|
||||
3. **出现冲动**:等待24小时再决策
|
||||
4. **大跌时**:先离开电脑,避免恐慌性卖出
|
||||
|
||||
---
|
||||
|
||||
### 5. 对标指数超额收益拆解
|
||||
|
||||
**功能说明**:
|
||||
对比组合收益与沪深300指数,判断是否跑赢大盘。
|
||||
|
||||
**指标定义**:
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| 组合收益 | 你的投资组合收益率 |
|
||||
| 沪深300收益 | 同期大盘收益率 |
|
||||
| **超额收益** | 组合收益 - 沪深300收益 |
|
||||
|
||||
**超额收益解读**:
|
||||
|
||||
| 超额收益 | 解读 | 来源分析 |
|
||||
|----------|------|----------|
|
||||
| > +5% | 显著跑赢大盘 | 选股和择时能力强 |
|
||||
| 0 ~ +5% | 小幅跑赢 | 选股能力较好 |
|
||||
| -5% ~ 0 | 小幅跑输 | 与大盘持平,可优化 |
|
||||
| < -5% | 显著跑输 | 选股或择时失误 |
|
||||
|
||||
**实战案例**:
|
||||
|
||||
```
|
||||
案例1:显著跑赢
|
||||
组合收益:+28.5%
|
||||
沪深300:+15.2%
|
||||
超额收益:+13.3%
|
||||
|
||||
来源:选股能力贡献
|
||||
解读:组合表现优于大盘,说明选股和择时能力较好
|
||||
|
||||
建议:继续保持当前策略
|
||||
```
|
||||
|
||||
```
|
||||
案例2:显著跑输
|
||||
组合收益:-8.3%
|
||||
沪深300:+5.6%
|
||||
超额收益:-13.9%
|
||||
|
||||
来源:选股/择时失误
|
||||
解读:组合表现明显弱于大盘,建议反思选股逻辑和买卖时机
|
||||
|
||||
改进建议:
|
||||
1. 检查选股逻辑(是否追高、板块选择)
|
||||
2. 检查择时(是否频繁交易、追涨杀跌)
|
||||
3. 考虑指数基金作为底仓
|
||||
```
|
||||
|
||||
```
|
||||
案例3:熊市中少亏
|
||||
组合收益:-3.2%
|
||||
沪深300:-12.5%
|
||||
超额收益:+9.3%
|
||||
|
||||
来源:风险控制能力
|
||||
解读:熊市中跑赢大盘,说明风控做得好
|
||||
|
||||
建议:继续保持防守策略,等待市场转暖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整使用流程
|
||||
|
||||
### 步骤1:准备数据
|
||||
|
||||
**前置条件**:
|
||||
- 至少有5笔已平仓交易
|
||||
- 交易记录包含:买入日期、卖出日期、买入理由、情绪标签
|
||||
|
||||
**数据录入**:
|
||||
进入"组合交易" → "交易日志",录入交易记录
|
||||
|
||||
**必填字段**:
|
||||
- 代码、价格、数量、方向(买/卖)
|
||||
- 日期
|
||||
|
||||
**建议填写**:
|
||||
- 买入理由(如"技术突破""超跌反弹")
|
||||
- 情绪标签(如"理性""贪婪""恐惧")
|
||||
|
||||
---
|
||||
|
||||
### 步骤2:执行分析
|
||||
|
||||
1. 进入"组合交易" → "盈亏归因"
|
||||
2. 点击"刷新分析"
|
||||
3. 等待3-5秒
|
||||
4. 查看五大维度分析结果
|
||||
|
||||
---
|
||||
|
||||
### 步骤3:解读结果
|
||||
|
||||
#### 优先查看:
|
||||
|
||||
1. **超额收益**
|
||||
- 是否跑赢沪深300?
|
||||
- 如果跑输,重点看选股和择时
|
||||
|
||||
2. **持仓时长**
|
||||
- 哪个周期胜率最高?
|
||||
- 调整交易频率
|
||||
|
||||
3. **买入理由**
|
||||
- 哪些理由有效?
|
||||
- 放弃无效理由
|
||||
|
||||
4. **情绪标签**
|
||||
- 哪种情绪下表现最好?
|
||||
- 避免情绪化交易
|
||||
|
||||
---
|
||||
|
||||
### 步骤4:制定改进计划
|
||||
|
||||
**改进模板**:
|
||||
|
||||
```
|
||||
当前问题:
|
||||
- 超额收益 -8%,跑输大盘
|
||||
- 短线胜率只有35%
|
||||
- "跟风买入"理由胜率30%
|
||||
- "贪婪"情绪下亏损严重
|
||||
|
||||
改进计划:
|
||||
1. 选股:加强基本面研究,避免追高
|
||||
2. 周期:从短线转为中线(6-30天)
|
||||
3. 理由:放弃"跟风",只做"突破"和"反弹"
|
||||
4. 情绪:设置交易计划,严格执行,避免冲动
|
||||
5. 目标:3个月后超额收益转正
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### GET /api/portfolio/attribution
|
||||
持仓归因分析
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"stock_vs_timing": {
|
||||
"ok": true,
|
||||
"stock_ability": 15.3,
|
||||
"timing_ability": 72.5
|
||||
},
|
||||
"hold_period": {
|
||||
"ok": true,
|
||||
"short_term": {
|
||||
"count": 20,
|
||||
"win_rate": 65.0,
|
||||
"avg_return": 3.8,
|
||||
"avg_days": 3.2,
|
||||
"total_pnl": 7600
|
||||
},
|
||||
"mid_term": {...},
|
||||
"long_term": {...},
|
||||
"recommendation": "建议重点关注中线(6-30天)"
|
||||
},
|
||||
"reason_validity": {
|
||||
"ok": true,
|
||||
"by_reason": [
|
||||
{
|
||||
"reason": "突破买入",
|
||||
"count": 15,
|
||||
"win_rate": 73.3,
|
||||
"avg_return": 6.8,
|
||||
"total_pnl": 10200,
|
||||
"effectiveness": "有效"
|
||||
}
|
||||
]
|
||||
},
|
||||
"emotion_correlation": {
|
||||
"ok": true,
|
||||
"by_emotion": [...],
|
||||
"advice": "最佳情绪状态:理性..."
|
||||
},
|
||||
"excess_return": {
|
||||
"ok": true,
|
||||
"portfolio_return": 28.5,
|
||||
"index_return": 15.2,
|
||||
"excess_return": 13.3,
|
||||
"source": "选股能力贡献",
|
||||
"interpretation": "组合表现优于大盘...",
|
||||
"period": "2023-01-01 ~ 2024-01-15"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实战案例
|
||||
|
||||
### 案例1:从跑输到跑赢
|
||||
|
||||
**初始状态**:
|
||||
```
|
||||
超额收益:-10.5%
|
||||
短线胜率:38%
|
||||
主要理由:跟风买入(胜率28%)
|
||||
主要情绪:贪婪(胜率25%)
|
||||
```
|
||||
|
||||
**改进措施**:
|
||||
1. 改为中线持仓(20天左右)
|
||||
2. 只做"突破"和"超跌反弹"
|
||||
3. 每次交易前写计划,标注"理性"
|
||||
4. 设置止损止盈,严格执行
|
||||
|
||||
**3个月后**:
|
||||
```
|
||||
超额收益:+8.2%
|
||||
中线胜率:68%
|
||||
主要理由:突破买入(胜率70%)
|
||||
主要情绪:理性(胜率72%)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 案例2:找到自己的节奏
|
||||
|
||||
**初始困惑**:
|
||||
不知道短线好还是长线好
|
||||
|
||||
**数据分析**:
|
||||
```
|
||||
短线(≤5天):
|
||||
- 胜率:48%
|
||||
- 平均收益:+1.2%
|
||||
|
||||
中线(6-30天):
|
||||
- 胜率:71%
|
||||
- 平均收益:+8.5%
|
||||
|
||||
长线(>30天):
|
||||
- 胜率:80%
|
||||
- 平均收益:+18.3%
|
||||
```
|
||||
|
||||
**结论**:
|
||||
适合长线持有,越拿越赚
|
||||
|
||||
**新策略**:
|
||||
- 减少交易频率
|
||||
- 选择优质股票长期持有
|
||||
- 目标持仓周期:60天以上
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据质量**:
|
||||
- 至少需要5笔已平仓交易
|
||||
- 买入理由和情绪标签要真实填写
|
||||
|
||||
2. **分析周期**:
|
||||
- 建议每月分析一次
|
||||
- 数据积累越多,分析越准确
|
||||
|
||||
3. **避免过度解读**:
|
||||
- 样本量小时结论可能不可靠
|
||||
- 至少20笔交易后再做重大调整
|
||||
|
||||
4. **结合市场环境**:
|
||||
- 牛市和熊市的策略不同
|
||||
- 分析时考虑大盘走势
|
||||
|
||||
5. **持续改进**:
|
||||
- 归因分析是诊断工具,不是灵丹妙药
|
||||
- 需要根据分析结果调整策略,并持续跟踪
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 增加月度/季度对比
|
||||
- [ ] 支持自定义理由和情绪标签
|
||||
- [ ] 增加行业归因分析
|
||||
- [ ] 支持多个基准指数对比
|
||||
- [ ] 生成PDF归因报告
|
||||
- [ ] 历史归因数据对比(本月 vs 上月)
|
||||
501
功能拓展/智能选股增强使用说明.md
Normal file
501
功能拓展/智能选股增强使用说明.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# 智能选股增强功能使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
智能选股增强模块提供了可视化的多条件选股器,支持策略保存、历史回测、结果对比等功能,帮助投资者快速筛选符合条件的股票。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 可视化选股器
|
||||
|
||||
**功能说明**:
|
||||
通过拖拽式界面组合多个选股条件,无需编写代码即可完成复杂选股。
|
||||
|
||||
**支持的条件字段**:
|
||||
|
||||
| 字段 | 说明 | 示例条件 |
|
||||
|------|------|----------|
|
||||
| **价格类** |
|
||||
| 现价 | 最新收盘价 | > 10元 |
|
||||
| 涨跌幅 | 当日涨跌幅 | > 3% |
|
||||
| 5日涨幅 | 近5日累计涨幅 | > 10% |
|
||||
| 20日涨幅 | 近20日累计涨幅 | > 20% |
|
||||
| 60日涨幅 | 近60日累计涨幅 | < -15%(超跌)|
|
||||
| **技术指标** |
|
||||
| MA5/10/20/60 | 各周期均线 | MA5 > MA20 |
|
||||
| RSI | 相对强弱指标 | < 30(超卖)|
|
||||
| 60日分位 | 价格在60日区间的位置 | < 0.2(低位)|
|
||||
| 均线多头 | MA5>MA10>MA20 | = true |
|
||||
| MACD金叉 | MACD快线上穿慢线 | = true |
|
||||
| **量能类** |
|
||||
| 量比 | 当日量/近期均量 | > 2(放量)|
|
||||
| 成交额 | 日成交金额 | > 5亿 |
|
||||
| 连涨天数 | 连续上涨天数 | >= 3 |
|
||||
|
||||
**支持的操作符**:
|
||||
- `>` 大于
|
||||
- `>=` 大于等于
|
||||
- `<` 小于
|
||||
- `<=` 小于等于
|
||||
|
||||
**组合逻辑**:
|
||||
- **AND(全部满足)**:所有条件都满足才入选
|
||||
- **OR(任一满足)**:满足任意一个条件即入选
|
||||
|
||||
**使用步骤**:
|
||||
1. 进入"选股引擎" → "智能选股器"
|
||||
2. 点击"➕ 添加条件"
|
||||
3. 选择字段、操作符、设置数值
|
||||
4. 继续添加条件(可添加多个)
|
||||
5. 选择组合逻辑(AND/OR)
|
||||
6. 点击"🔍 执行选股"
|
||||
|
||||
**示例1:强势突破选股**
|
||||
```
|
||||
策略名称:强势突破
|
||||
逻辑:AND(全部满足)
|
||||
条件:
|
||||
1. 5日涨幅 > 10%
|
||||
2. 量比 > 2
|
||||
3. RSI < 80
|
||||
4. 成交额 > 5亿
|
||||
|
||||
结果:筛选出短期强势且放量的股票
|
||||
```
|
||||
|
||||
**示例2:超跌反弹选股**
|
||||
```
|
||||
策略名称:超跌反弹
|
||||
逻辑:AND
|
||||
条件:
|
||||
1. 20日涨幅 < -15%(超跌)
|
||||
2. RSI < 30(超卖)
|
||||
3. 当日涨幅 > 2%(企稳反弹)
|
||||
4. 成交额 > 3亿(有资金关注)
|
||||
|
||||
结果:筛选出超跌后开始反弹的股票
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 预设策略
|
||||
|
||||
**功能说明**:
|
||||
系统内置4个常用选股策略,可一键加载使用。
|
||||
|
||||
#### 策略1:动量突破
|
||||
**理念**:捕捉短期强势股
|
||||
**条件**:
|
||||
- 5日涨幅 > 10%
|
||||
- 量比 > 2
|
||||
- RSI < 80
|
||||
|
||||
**适用市场**:强势市、上涨趋势
|
||||
**风险**:追高风险,需设置止损
|
||||
|
||||
#### 策略2:价值洼地
|
||||
**理念**:寻找低位价值股
|
||||
**条件**:
|
||||
- 60日分位 < 30%(低位)
|
||||
- 当日涨幅 > 0(企稳)
|
||||
- 成交额 > 5亿(流动性好)
|
||||
|
||||
**适用市场**:震荡市、底部区域
|
||||
**风险**:可能继续下跌,需耐心等待
|
||||
|
||||
#### 策略3:成长加速
|
||||
**理念**:寻找持续上涨的成长股
|
||||
**条件**:
|
||||
- 20日涨幅 > 15%
|
||||
- 均线多头 = true
|
||||
- 连涨天数 >= 2
|
||||
|
||||
**适用市场**:牛市、趋势明确
|
||||
**风险**:涨幅过大可能回调
|
||||
|
||||
#### 策略4:反转抄底
|
||||
**理念**:超跌后的技术反转
|
||||
**条件**:
|
||||
- 20日跌幅 < -15%
|
||||
- RSI < 30
|
||||
- 当日涨幅 > 2%
|
||||
|
||||
**适用市场**:超跌反弹
|
||||
**风险**:可能是下跌中继,需严格止损
|
||||
|
||||
**使用方法**:
|
||||
1. 点击"加载预设"
|
||||
2. 输入序号(1-4)
|
||||
3. 系统自动填充条件
|
||||
4. 可在预设基础上修改
|
||||
|
||||
---
|
||||
|
||||
### 3. 策略保存与分享
|
||||
|
||||
**功能说明**:
|
||||
将配置好的选股策略保存到数据库,方便后续重复使用。
|
||||
|
||||
**使用步骤**:
|
||||
1. 配置好选股条件
|
||||
2. 点击"保存策略"
|
||||
3. 输入策略描述(可选)
|
||||
4. 保存成功后获得策略ID
|
||||
|
||||
**策略管理**:
|
||||
```
|
||||
GET /api/selector/strategies # 获取保存的策略列表
|
||||
GET /api/selector/strategies/{id} # 获取策略详情
|
||||
DELETE /api/selector/strategies/{id} # 删除策略
|
||||
```
|
||||
|
||||
**分享方式**:
|
||||
- 导出策略JSON
|
||||
- 分享策略ID
|
||||
- 复制策略配置
|
||||
|
||||
---
|
||||
|
||||
### 4. 选股结果回测
|
||||
|
||||
**功能说明**:
|
||||
对选股策略进行历史回测,验证策略的有效性。
|
||||
|
||||
**回测逻辑**:
|
||||
1. 逐日执行选股(回测期内每个交易日)
|
||||
2. 记录选中的股票
|
||||
3. 计算这些股票5日后的平均收益
|
||||
4. 统计胜率、最大收益等指标
|
||||
|
||||
**回测指标**:
|
||||
|
||||
| 指标 | 说明 | 优秀值 |
|
||||
|------|------|--------|
|
||||
| 平均收益 | 5日平均收益率 | > 5% |
|
||||
| 平均胜率 | 上涨股票占比 | > 60% |
|
||||
| 最佳日 | 表现最好的一天 | 参考 |
|
||||
| 最差日 | 表现最差的一天 | 避免大幅亏损 |
|
||||
|
||||
**使用步骤**:
|
||||
1. 配置好选股条件
|
||||
2. 点击"回测验证"
|
||||
3. 输入回测天数(20-250天)
|
||||
4. 等待回测完成(约10-30秒)
|
||||
5. 查看回测结果
|
||||
|
||||
**结果解读**:
|
||||
```
|
||||
回测结果(60天)
|
||||
- 平均收益:6.5% ✓ 策略有效
|
||||
- 平均胜率:68% ✓ 胜率较高
|
||||
- 最佳日:+12.3%
|
||||
- 最差日:-3.2% ✓ 回撤可控
|
||||
|
||||
结论:该策略在过去60天表现良好,可考虑实盘应用
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- 回测基于历史数据,不代表未来表现
|
||||
- 回测期应覆盖不同市场环境(牛市、熊市、震荡市)
|
||||
- 过度优化可能导致过拟合
|
||||
|
||||
---
|
||||
|
||||
### 5. 选股结果对比
|
||||
|
||||
**功能说明**:
|
||||
对比今日和昨日的选股结果,识别新入选、退出、持续入选的股票。
|
||||
|
||||
**使用场景**:
|
||||
- 发现新的投资机会(新入选)
|
||||
- 警惕风险(持续入选但开始下跌)
|
||||
- 分析策略稳定性
|
||||
|
||||
**对比维度**:
|
||||
|
||||
| 类型 | 说明 | 操作建议 |
|
||||
|------|------|----------|
|
||||
| **新入选** | 今日新符合条件 | 重点关注,可能是新机会 |
|
||||
| **退出** | 昨日入选,今日不符合 | 检查原因,考虑止盈/止损 |
|
||||
| **持续入选** | 连续多日符合条件 | 趋势延续,可继续持有 |
|
||||
|
||||
**使用步骤**:
|
||||
1. 先在"智能选股器"配置策略
|
||||
2. 进入"选股结果对比"
|
||||
3. 点击"执行对比"
|
||||
4. 查看新入选、退出、持续入选的股票
|
||||
|
||||
**实战应用**:
|
||||
```
|
||||
情况1:新入选10只,退出5只
|
||||
→ 市场活跃,有新热点
|
||||
→ 关注新入选股票
|
||||
|
||||
情况2:新入选0只,退出15只
|
||||
→ 策略信号减弱
|
||||
→ 市场可能转向,谨慎操作
|
||||
|
||||
情况3:持续入选30只
|
||||
→ 策略稳定
|
||||
→ 趋势延续,可放心持有
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整使用流程
|
||||
|
||||
### 场景1:新手快速上手
|
||||
|
||||
**步骤**:
|
||||
1. 打开"智能选股器"
|
||||
2. 点击"加载预设" → 选择"动量突破"
|
||||
3. 点击"🔍 执行选股"
|
||||
4. 查看选股结果
|
||||
5. 点击"回测验证",输入60天
|
||||
6. 确认策略有效后,点击"保存策略"
|
||||
|
||||
**耗时**:5分钟
|
||||
|
||||
---
|
||||
|
||||
### 场景2:自定义策略开发
|
||||
|
||||
**步骤**:
|
||||
1. 分析需求:我想找"低位企稳+放量"的股票
|
||||
2. 添加条件:
|
||||
- 60日分位 < 0.3(低位)
|
||||
- 当日涨幅 > 1%(企稳)
|
||||
- 量比 > 1.5(放量)
|
||||
- 成交额 > 3亿(流动性)
|
||||
3. 选择逻辑:AND
|
||||
4. 执行选股,查看结果
|
||||
5. 回测验证(60天)
|
||||
6. 根据回测结果调整条件
|
||||
7. 满意后保存策略
|
||||
|
||||
**耗时**:15-30分钟
|
||||
|
||||
---
|
||||
|
||||
### 场景3:策略日常维护
|
||||
|
||||
**每日流程**:
|
||||
1. 早盘前(9:00-9:25)
|
||||
- 进入"选股结果对比"
|
||||
- 查看新入选股票
|
||||
- 关注退出股票(可能需要止盈/止损)
|
||||
|
||||
2. 盘中(10:30-14:30)
|
||||
- 观察新入选股票的表现
|
||||
- 结合盘面验证策略
|
||||
|
||||
3. 收盘后(15:30-16:00)
|
||||
- 执行选股,记录结果
|
||||
- 每周回测一次,验证策略有效性
|
||||
|
||||
**耗时**:每日10分钟
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/selector/run
|
||||
执行选股
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"name": "强势突破",
|
||||
"description": "短期强势+放量",
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
{"field": "ret5", "operator": ">", "value": 10},
|
||||
{"field": "vol_ratio", "operator": ">", "value": 2},
|
||||
{"field": "rsi14", "operator": "<", "value": 80}
|
||||
]
|
||||
},
|
||||
"date": "2024-01-15" // 可选,null表示最新
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"date": "2024-01-15",
|
||||
"strategy": "强势突破",
|
||||
"count": 25,
|
||||
"results": [
|
||||
{
|
||||
"code": "600519",
|
||||
"name": "贵州茅台",
|
||||
"close": 1680.5,
|
||||
"pct": 3.2,
|
||||
"ret5": 12.5,
|
||||
"ret20": 18.3,
|
||||
"vol_ratio": 2.8,
|
||||
"rsi14": 68.5,
|
||||
"amount": 35.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/selector/backtest
|
||||
策略回测
|
||||
|
||||
**请求体**:同上
|
||||
**参数**:`days`(回测天数,20-250)
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"strategy": "强势突破",
|
||||
"days": 60,
|
||||
"summary": {
|
||||
"total_days": 42,
|
||||
"avg_return": 6.5,
|
||||
"avg_win_rate": 68.2,
|
||||
"best_day": {...},
|
||||
"worst_day": {...}
|
||||
},
|
||||
"daily": [
|
||||
{
|
||||
"date": "2023-12-01",
|
||||
"count": 18,
|
||||
"avg_return": 5.3,
|
||||
"win_rate": 72.2,
|
||||
"max_return": 15.6,
|
||||
"min_return": -3.2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/selector/compare
|
||||
对比选股结果
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"strategy": {...},
|
||||
"date1": "2024-01-14",
|
||||
"date2": "2024-01-15"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/selector/strategies
|
||||
保存策略
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"name": "我的策略",
|
||||
"description": "策略描述",
|
||||
"strategy": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/selector/strategies
|
||||
获取保存的策略列表
|
||||
|
||||
### GET /api/selector/strategies/{id}
|
||||
获取策略详情
|
||||
|
||||
---
|
||||
|
||||
## 高级技巧
|
||||
|
||||
### 技巧1:多策略组合
|
||||
|
||||
不要只依赖单一策略,建议配置3-5个不同风格的策略:
|
||||
|
||||
```
|
||||
策略1:动量突破(激进)
|
||||
策略2:价值洼地(稳健)
|
||||
策略3:反转抄底(逆向)
|
||||
```
|
||||
|
||||
每日执行3个策略,取交集或并集。
|
||||
|
||||
---
|
||||
|
||||
### 技巧2:动态调整阈值
|
||||
|
||||
根据市场环境调整条件阈值:
|
||||
|
||||
```
|
||||
牛市:
|
||||
- 5日涨幅 > 15%(更激进)
|
||||
- 量比 > 3
|
||||
|
||||
震荡市:
|
||||
- 5日涨幅 > 8%(适中)
|
||||
- 量比 > 2
|
||||
|
||||
熊市:
|
||||
- 5日涨幅 > 5%(保守)
|
||||
- 量比 > 1.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 技巧3:结合板块分析
|
||||
|
||||
选股后,查看"板块分析" → "强弱趋势":
|
||||
- 优先选择强势板块的股票
|
||||
- 避开弱势板块
|
||||
|
||||
---
|
||||
|
||||
### 技巧4:设置后验条件
|
||||
|
||||
选股后人工复核:
|
||||
- 查看K线形态
|
||||
- 检查基本面
|
||||
- 避开ST、*ST
|
||||
- 避开涨停板(无法买入)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据依赖**:
|
||||
- 需要先在"数据中台"入库 `stock_metrics` 表
|
||||
- 数据越完整,选股越准确
|
||||
|
||||
2. **回测陷阱**:
|
||||
- 历史表现不代表未来
|
||||
- 过度优化导致过拟合
|
||||
- 样本外测试很重要
|
||||
|
||||
3. **实盘差异**:
|
||||
- 回测基于收盘价,实盘有滑点
|
||||
- 涨停板无法买入
|
||||
- 流动性限制
|
||||
|
||||
4. **风险控制**:
|
||||
- 选股只是第一步
|
||||
- 仍需设置止损止盈
|
||||
- 控制仓位
|
||||
|
||||
5. **策略失效**:
|
||||
- 市场环境变化
|
||||
- 策略被广泛使用后失效
|
||||
- 定期回测验证
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 拖拽式可视化界面
|
||||
- [ ] 更多技术指标(KDJ、布林带)
|
||||
- [ ] 基本面指标(PE、ROE、营收增速)
|
||||
- [ ] 策略分享社区
|
||||
- [ ] 实时预警推送
|
||||
- [ ] 选股结果排序(按某个指标)
|
||||
- [ ] 导出选股结果
|
||||
- [ ] 批量回测多个策略
|
||||
</contents>
|
||||
403
功能拓展/板块轮动分析使用说明.md
Normal file
403
功能拓展/板块轮动分析使用说明.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# 板块轮动分析使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
板块轮动分析模块帮助投资者识别市场热点板块、追踪资金流向、判断板块所处生命周期,从而把握板块轮动机会。
|
||||
|
||||
## 五大核心功能
|
||||
|
||||
### 1. 板块强弱趋势
|
||||
|
||||
**功能说明**:
|
||||
展示近期(5日/10日/20日)各板块涨跌幅排名和趋势曲线。
|
||||
|
||||
**使用场景**:
|
||||
- 快速识别强势板块和弱势板块
|
||||
- 观察板块强弱变化趋势
|
||||
- 对比不同周期的板块表现
|
||||
|
||||
**操作步骤**:
|
||||
1. 进入"板块分析" → "强弱趋势"
|
||||
2. 切换时间周期(5日/10日/20日)
|
||||
3. 查看前15个板块的涨跌幅曲线
|
||||
|
||||
**指标说明**:
|
||||
- **5日收益**:近5个交易日累计涨跌幅
|
||||
- **10日收益**:近10个交易日累计涨跌幅
|
||||
- **20日收益**:近20个交易日累计涨跌幅(复利计算)
|
||||
- **平均成交额**:日均成交金额
|
||||
- **波动率**:价格波动的标准差
|
||||
|
||||
**使用技巧**:
|
||||
- 持续上涨的板块可能处于主升浪
|
||||
- 涨幅回落的板块可能进入调整期
|
||||
- 成交额放大的板块往往有资金关注
|
||||
|
||||
---
|
||||
|
||||
### 2. 资金流向分析
|
||||
|
||||
**功能说明**:
|
||||
通过桑基图直观展示板块间资金流动,识别资金从哪里流出、流向哪里。
|
||||
|
||||
**使用场景**:
|
||||
- 追踪市场热钱流向
|
||||
- 发现资金流入的新热点
|
||||
- 警惕资金流出的板块
|
||||
|
||||
**操作步骤**:
|
||||
1. 进入"板块分析" → "资金流向"
|
||||
2. 选择统计周期(1日/3日/5日/10日)
|
||||
3. 查看桑基图和资金流入/流出排行
|
||||
|
||||
**图表解读**:
|
||||
- **左侧**:资金净流出的板块
|
||||
- **中间**:资金池(转换枢纽)
|
||||
- **右侧**:资金净流入的板块
|
||||
- **线条粗细**:代表资金流动量
|
||||
|
||||
**投资策略**:
|
||||
```
|
||||
顺势策略:跟随资金流入的板块
|
||||
逆向策略:关注资金流出但基本面良好的板块(可能超跌)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```
|
||||
观察到:
|
||||
- 资金从"地产""银行"流出
|
||||
- 资金流入"半导体""新能源"
|
||||
|
||||
解读:
|
||||
市场风格从传统周期股切换到科技成长股
|
||||
可考虑配置流入板块的龙头股
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 板块生命周期
|
||||
|
||||
**功能说明**:
|
||||
判断板块当前处于哪个阶段,辅助买卖决策。
|
||||
|
||||
**生命周期阶段**:
|
||||
|
||||
| 阶段 | 特征 | 策略 |
|
||||
|------|------|------|
|
||||
| **启动期** | 刚开始上涨,资金流入加速 | 积极介入,低位布局 |
|
||||
| **加速期** | 持续上涨且加速,成交活跃 | 持有为主,主升浪 |
|
||||
| **衰退期** | 涨幅收窄或开始回调 | 逐步减仓,注意风险 |
|
||||
| **下跌期** | 持续下跌 | 避免介入,等待企稳 |
|
||||
| **震荡期** | 横盘整理,方向不明 | 观望,等待方向明确 |
|
||||
|
||||
**判断依据**:
|
||||
1. **涨跌幅趋势**:
|
||||
- 20日涨幅 > 0 且加速 → 启动期/加速期
|
||||
- 20日涨幅 > 0 但减速 → 衰退期
|
||||
- 20日跌幅 > 5% → 下跌期
|
||||
|
||||
2. **成交额变化**:
|
||||
- 近5日成交额 / 近20日成交额 > 1.2 → 资金流入
|
||||
- < 0.8 → 资金流出
|
||||
|
||||
3. **动量指标**:
|
||||
- 5日涨幅 - 10日涨幅 > 0 → 加速
|
||||
- < 0 → 减速
|
||||
|
||||
**操作步骤**:
|
||||
1. 进入"板块分析" → "生命周期"
|
||||
2. 输入板块名称(如"半导体""新能源""医药")
|
||||
3. 点击"分析"
|
||||
4. 查看生命周期阶段和建议
|
||||
|
||||
**使用示例**:
|
||||
```
|
||||
板块:半导体
|
||||
阶段:加速期
|
||||
5日涨幅:+8.5%
|
||||
20日涨幅:+23.7%
|
||||
成交额变化:+45.2%
|
||||
|
||||
解读:
|
||||
板块处于主升浪,持续上涨且加速,成交额放大明显。
|
||||
建议:持有为主,可适当加仓龙头股。
|
||||
风险:注意短期涨幅过大,设好止盈位。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 龙头股识别
|
||||
|
||||
**功能说明**:
|
||||
自动识别板块内的龙头股票(涨幅领先 + 成交额靠前)。
|
||||
|
||||
**识别标准**:
|
||||
1. 近20日涨幅居前
|
||||
2. 日均成交额 > 5亿(流动性好)
|
||||
3. 名称包含板块关键词(简化匹配)
|
||||
|
||||
**操作步骤**:
|
||||
1. 进入"板块分析" → "龙头股"
|
||||
2. 输入板块名称
|
||||
3. 点击"查询"
|
||||
4. 查看龙头股排名
|
||||
|
||||
**指标说明**:
|
||||
- **现价**:最新收盘价
|
||||
- **涨跌幅**:当日涨跌幅
|
||||
- **5日涨幅**:短期表现
|
||||
- **20日涨幅**:中期表现
|
||||
- **成交额**:日均成交金额(流动性指标)
|
||||
- **量比**:当日成交量 / 近20日均量
|
||||
|
||||
**投资策略**:
|
||||
```
|
||||
强者恒强:优先配置排名前3的龙头股
|
||||
分散持仓:龙头 + 二线股组合,平衡收益和风险
|
||||
轮动操作:龙头股涨幅过大时,切换到涨幅较小的潜力股
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- 当前基于名称关键词匹配,可能不完全准确
|
||||
- 实际应用建议建立股票-板块映射表
|
||||
- 龙头股不等于买入信号,需结合技术面和估值
|
||||
|
||||
---
|
||||
|
||||
### 5. 板块联动性分析
|
||||
|
||||
**功能说明**:
|
||||
通过相关系数热力图,分析板块之间的联动关系。
|
||||
|
||||
**使用场景**:
|
||||
- 发现高度相关的板块对
|
||||
- 分散投资时避免同涨同跌的板块
|
||||
- 识别产业链上下游关联
|
||||
|
||||
**相关系数解读**:
|
||||
|
||||
| 相关系数 | 含义 | 投资启示 |
|
||||
|----------|------|----------|
|
||||
| 0.9 - 1.0 | 极强正相关 | 几乎同涨同跌,分散效果差 |
|
||||
| 0.7 - 0.9 | 强正相关 | 联动性强,可能有产业链关系 |
|
||||
| 0.3 - 0.7 | 中度正相关 | 有一定联动性 |
|
||||
| -0.3 - 0.3 | 弱相关 | 独立性较强,分散效果好 |
|
||||
| -0.7 - -0.3 | 中度负相关 | 此消彼长 |
|
||||
| < -0.7 | 强负相关 | 跷跷板效应 |
|
||||
|
||||
**操作步骤**:
|
||||
1. 进入"板块分析" → "联动性"
|
||||
2. 查看热力图(红色=正相关,绿色=负相关)
|
||||
3. 查看高度相关板块对列表
|
||||
|
||||
**投资应用**:
|
||||
|
||||
**案例1:分散投资**
|
||||
```
|
||||
如果持有"半导体"板块,
|
||||
查询发现"半导体"与"新能源"相关系数 0.85(强相关),
|
||||
则不宜同时重仓这两个板块,
|
||||
应选择相关系数 < 0.5 的板块进行分散。
|
||||
```
|
||||
|
||||
**案例2:产业链联动**
|
||||
```
|
||||
发现"新能源汽车"与"锂电池"相关系数 0.92,
|
||||
说明产业链联动性强,
|
||||
当新能源汽车板块启动时,锂电池板块往往也会上涨。
|
||||
```
|
||||
|
||||
**案例3:风格轮动**
|
||||
```
|
||||
发现"科技成长"与"银行地产"相关系数 -0.6(负相关),
|
||||
说明市场风格在两者间切换,
|
||||
当科技股回调时,可关注银行地产的反弹机会。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### GET /api/sector/trend
|
||||
板块强弱趋势
|
||||
|
||||
**参数**:
|
||||
- `days`:统计天数(5-60),默认 20
|
||||
- `top_n`:返回数量(<=30),默认 15
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"date": "2024-01-15",
|
||||
"days": 20,
|
||||
"sectors": [
|
||||
{
|
||||
"name": "半导体",
|
||||
"returns": {
|
||||
"5d": 8.5,
|
||||
"10d": 15.3,
|
||||
"20d": 23.7
|
||||
},
|
||||
"avg_amount": 3520.5,
|
||||
"volatility": 2.8,
|
||||
"dates": [...],
|
||||
"pcts": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/sector/flow
|
||||
资金流向分析
|
||||
|
||||
**参数**:
|
||||
- `days`:统计天数(1-20),默认 5
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"days": 5,
|
||||
"total_inflow": 235.8,
|
||||
"total_outflow": 198.3,
|
||||
"top_inflow": [["半导体", 85.6], ...],
|
||||
"top_outflow": [["地产", 62.3], ...],
|
||||
"sankey": {
|
||||
"nodes": [...],
|
||||
"links": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/sector/lifecycle
|
||||
板块生命周期
|
||||
|
||||
**参数**:
|
||||
- `name`:板块名称(必填)
|
||||
- `days`:分析天数(20-120),默认 60
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"sector": "半导体",
|
||||
"phase": "加速期",
|
||||
"description": "板块持续上涨且加速,成交活跃,主升浪阶段",
|
||||
"metrics": {
|
||||
"return_5d": 8.5,
|
||||
"return_10d": 15.3,
|
||||
"return_20d": 23.7,
|
||||
"momentum": -6.8,
|
||||
"amount_change": 45.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/sector/leaders
|
||||
龙头股识别
|
||||
|
||||
**参数**:
|
||||
- `name`:板块名称(必填)
|
||||
- `days`:统计天数(5-60),默认 20
|
||||
- `limit`:返回数量(<=30),默认 10
|
||||
|
||||
### GET /api/sector/correlation
|
||||
板块联动性分析
|
||||
|
||||
**参数**:
|
||||
- `days`:计算天数(20-120),默认 60
|
||||
- `top_n`:分析板块数(<=30),默认 20
|
||||
|
||||
### GET /api/sector/summary
|
||||
板块轮动摘要
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"strongest_sectors": [...],
|
||||
"weakest_sectors": [...],
|
||||
"fund_flow": {...}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实战策略
|
||||
|
||||
### 策略1:板块轮动捕捉法
|
||||
|
||||
**步骤**:
|
||||
1. 每日查看"强弱趋势",识别近5日涨幅前5的板块
|
||||
2. 进入"生命周期",确认板块处于启动期或加速期
|
||||
3. 查看"龙头股",选择涨幅和成交额靠前的2-3只
|
||||
4. 观察"资金流向",确认有资金持续流入
|
||||
5. 买入并设置止盈(如+15%)和止损(如-5%)
|
||||
|
||||
**适用市场**:
|
||||
强势市场、板块轮动明显时
|
||||
|
||||
---
|
||||
|
||||
### 策略2:衰退期预警法
|
||||
|
||||
**步骤**:
|
||||
1. 持仓板块每周检查一次生命周期
|
||||
2. 若进入"衰退期",逐步减仓50%
|
||||
3. 若5日涨幅转负,清仓离场
|
||||
4. 资金切换到处于"启动期"的新板块
|
||||
|
||||
**适用场景**:
|
||||
板块行情见顶,及时止盈
|
||||
|
||||
---
|
||||
|
||||
### 策略3:联动对冲法
|
||||
|
||||
**步骤**:
|
||||
1. 查看"联动性",找出强负相关的板块对
|
||||
2. 同时配置两个板块,降低组合波动
|
||||
3. 如:科技(60%) + 银行(40%)
|
||||
4. 当一个板块回调时,另一个可能上涨
|
||||
|
||||
**适用场景**:
|
||||
震荡市、追求稳健收益
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据依赖**:
|
||||
- 需要先在"数据中台"入库板块和资金流数据
|
||||
- 建议每日收盘后执行一次入库
|
||||
|
||||
2. **板块分类**:
|
||||
- 当前使用 AkShare 的板块分类
|
||||
- 不同数据源的板块分类可能不同
|
||||
|
||||
3. **龙头股识别**:
|
||||
- 目前基于名称关键词匹配
|
||||
- 实际生产环境建议建立股票-板块映射表
|
||||
|
||||
4. **相关性稳定性**:
|
||||
- 相关系数会随市场环境变化
|
||||
- 建议定期(如每月)重新计算
|
||||
|
||||
5. **风险提示**:
|
||||
- 板块轮动分析是辅助工具,不构成投资建议
|
||||
- 需结合基本面、技术面综合判断
|
||||
- 设置止损,控制风险
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 建立股票-板块映射表(精确匹配)
|
||||
- [ ] 增加行业指数对比
|
||||
- [ ] 板块轮动历史回测
|
||||
- [ ] 主力资金流向追踪
|
||||
- [ ] 板块估值分析(PE/PB)
|
||||
- [ ] 政策事件对板块影响分析
|
||||
- [ ] 板块强弱评分模型
|
||||
</contents>
|
||||
135
功能拓展/盘中异动雷达使用说明.md
Normal file
135
功能拓展/盘中异动雷达使用说明.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 盘中异动雷达功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
盘中异动雷达是Blackdata股票终端的实时监控模块,能够在交易时间内自动检测市场异动并推送提醒。
|
||||
|
||||
## 监控类型
|
||||
|
||||
### 1. 快速拉升(surge)
|
||||
- **触发条件**:股价涨幅 ≥ 3%
|
||||
- **检测频率**:每60秒
|
||||
- **数据源**:实时涨幅榜前50
|
||||
|
||||
### 2. 放量突破(volume_break)
|
||||
- **触发条件**:
|
||||
- 量比 ≥ 3
|
||||
- 价格上涨
|
||||
- 突破关键位(60日分位 ≥ 95% 或突破MA20)
|
||||
- **检测频率**:每60秒
|
||||
- **数据源**:stock_metrics 表
|
||||
|
||||
### 3. 涨停打开/炸板(limit_open)
|
||||
- **触发条件**:涨幅在 9.5% ~ 9.99% 之间
|
||||
- **检测频率**:每60秒
|
||||
- **用途**:捕捉涨停板打开的买入/卖出时机
|
||||
|
||||
### 4. 连板股追踪(consecutive)
|
||||
- **触发条件**:连续2个及以上交易日涨停
|
||||
- **检测频率**:每60秒
|
||||
- **数据源**:历史日线数据
|
||||
|
||||
### 5. 大单异动(big_order)
|
||||
- **状态**:功能保留,需要接入付费逐笔数据源
|
||||
- **触发条件**:单笔成交金额 ≥ 100万元
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 自动监控(推荐)
|
||||
系统会在交易时间(周一至周五 9:30-11:30, 13:00-15:00)每60秒自动扫描,检测到异动后:
|
||||
1. 自动写入 `intraday_events` 表
|
||||
2. 自动推送到配置的通知渠道(邮件/微信)
|
||||
|
||||
### 手动操作
|
||||
在前端"异动雷达"页面:
|
||||
- **立即扫描**:手动触发一次全市场扫描
|
||||
- **推送异动**:将未推送的异动事件立即推送
|
||||
- **查看统计**:查看今日各类异动的数量和最活跃股票
|
||||
|
||||
## API 接口
|
||||
|
||||
### GET /api/radar/status
|
||||
检查雷达状态(是否交易时间)
|
||||
|
||||
### POST /api/radar/scan
|
||||
手动触发异动扫描
|
||||
- **返回**:扫描结果,包含检测到的异动数量
|
||||
|
||||
### GET /api/radar/events
|
||||
获取最近的异动事件
|
||||
- **参数**:
|
||||
- `hours`:时间范围(小时),默认2
|
||||
- `limit`:最大返回数量,默认50
|
||||
|
||||
### POST /api/radar/notify
|
||||
推送未通知的异动
|
||||
- **返回**:推送结果
|
||||
|
||||
### GET /api/radar/stats
|
||||
获取异动统计
|
||||
- **参数**:
|
||||
- `date`:统计日期,默认今天
|
||||
|
||||
## 数据表结构
|
||||
|
||||
### intraday_events
|
||||
```sql
|
||||
id SERIAL PRIMARY KEY
|
||||
code VARCHAR(12) -- 股票代码
|
||||
name VARCHAR(40) -- 股票名称
|
||||
event_type VARCHAR(20) -- 事件类型
|
||||
price FLOAT -- 触发时价格
|
||||
pct FLOAT -- 涨跌幅
|
||||
volume_ratio FLOAT -- 量比
|
||||
amount FLOAT -- 金额(大单用)
|
||||
description VARCHAR(200) -- 描述
|
||||
detected_at TIMESTAMP -- 检测时间
|
||||
notified BOOLEAN -- 是否已推送
|
||||
```
|
||||
|
||||
## 推送配置
|
||||
|
||||
在 `backend/.env` 中配置以下任一渠道:
|
||||
|
||||
```env
|
||||
# SMTP 邮件
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=your@email.com
|
||||
SMTP_PASSWORD=your_password
|
||||
SMTP_TO=target@email.com
|
||||
|
||||
# Server酱(微信)
|
||||
SERVERCHAN_KEY=your_key
|
||||
|
||||
# 企业微信
|
||||
WECOM_WEBHOOK=https://qyapi.weixin.qq.com/...
|
||||
|
||||
# PushPlus
|
||||
PUSHPLUS_TOKEN=your_token
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据依赖**:
|
||||
- 需要先执行"数据中台"的入库操作
|
||||
- stock_metrics 表需有最新数据
|
||||
- 连板检测依赖历史日线数据
|
||||
|
||||
2. **性能优化**:
|
||||
- 使用5分钟 TTL 缓存避免重复推送
|
||||
- 非交易时间自动跳过检测
|
||||
|
||||
3. **扩展方向**:
|
||||
- 接入分钟线数据源提升检测精度
|
||||
- 添加自定义监控规则
|
||||
- 增加异动回测功能
|
||||
|
||||
## 后续优化计划
|
||||
|
||||
- [ ] 支持用户自定义监控条件
|
||||
- [ ] 异动发生后的历史表现统计
|
||||
- [ ] 结合AI分析给出操作建议
|
||||
- [ ] 移动端推送优化
|
||||
- [ ] 盘中异动回放功能
|
||||
</contents>
|
||||
546
功能拓展/社区情绪监控使用说明.md
Normal file
546
功能拓展/社区情绪监控使用说明.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# 社区情绪监控使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
社区情绪监控模块通过爬取和分析东方财富、雪球等社区的热帖,量化散户情绪,提供反向投资指标。
|
||||
|
||||
**核心理念**:散户情绪往往是反向指标,当市场情绪极度乐观时可能是顶部,极度悲观时可能是底部。
|
||||
|
||||
---
|
||||
|
||||
## 五大核心功能
|
||||
|
||||
### 1. 情绪指数
|
||||
|
||||
**功能说明**:
|
||||
统计社区帖子的情绪倾向(乐观/悲观/中性),计算情绪指数。
|
||||
|
||||
**情绪分类**:
|
||||
|
||||
| 情绪 | 关键词示例 | 判断逻辑 |
|
||||
|------|------------|----------|
|
||||
| **乐观(Bullish)** | 看多、买入、突破、暴涨、牛市 | 包含2个及以上乐观关键词 |
|
||||
| **悲观(Bearish)** | 看空、卖出、下跌、暴跌、熊市 | 包含2个及以上悲观关键词 |
|
||||
| **中性(Neutral)** | 其他 | 情绪关键词不明显 |
|
||||
|
||||
**情绪指数定义**:
|
||||
```
|
||||
乐观比例 = 乐观帖子数 / 总帖子数 × 100%
|
||||
```
|
||||
|
||||
**反向指标解读**:
|
||||
|
||||
| 乐观比例 | 市场状态 | 投资建议 |
|
||||
|----------|----------|----------|
|
||||
| **≥ 80%** | 极度乐观 | ⚠️ 警惕顶部,考虑减仓 |
|
||||
| **60-80%** | 偏乐观 | 谨慎操作,不追高 |
|
||||
| **40-60%** | 中性 | 正常波动 |
|
||||
| **20-40%** | 偏悲观 | 关注机会,可适当建仓 |
|
||||
| **< 20%** | 极度悲观 | ✅ 可能是底部,积极布局 |
|
||||
|
||||
**使用步骤**:
|
||||
1. 进入"社区情绪" → "情绪指数"
|
||||
2. 点击"采集数据"(爬取最新帖子)
|
||||
3. 点击"计算指数"
|
||||
4. 查看乐观比例和历史趋势
|
||||
|
||||
**实战案例**:
|
||||
```
|
||||
案例1:2021年2月(牛市顶部)
|
||||
乐观比例:92%
|
||||
市场状态:极度乐观,到处都是"牛市来了""万点不是梦"
|
||||
实际走势:随后进入长达一年的调整
|
||||
|
||||
案例2:2022年10月(熊市底部)
|
||||
乐观比例:18%
|
||||
市场状态:极度悲观,"A股药丸""永不回本"
|
||||
实际走势:随后开启反弹
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 热议股票排行
|
||||
|
||||
**功能说明**:
|
||||
统计最近N天讨论量最高的股票,识别市场热点。
|
||||
|
||||
**热度评分公式**:
|
||||
```
|
||||
热度 = 帖子数 × 10 + 评论数
|
||||
```
|
||||
|
||||
**讨论量激增预警**:
|
||||
|
||||
当某只股票的讨论量突然激增,往往意味着:
|
||||
|
||||
✅ **正面信号**(早期):
|
||||
- 新热点启动
|
||||
- 基本面改善引发关注
|
||||
- 可能是买入机会
|
||||
|
||||
⚠️ **负面信号**(后期):
|
||||
- 追高情绪蔓延
|
||||
- 可能接近短期顶部
|
||||
- 谨慎追高
|
||||
|
||||
**使用技巧**:
|
||||
|
||||
**技巧1:结合股价走势**
|
||||
```
|
||||
如果热议股票尚未大涨(涨幅 < 10%):
|
||||
→ 可能是早期机会,值得关注
|
||||
|
||||
如果热议股票已经暴涨(涨幅 > 30%):
|
||||
→ 可能是追高情绪,谨慎介入
|
||||
```
|
||||
|
||||
**技巧2:观察持续性**
|
||||
```
|
||||
连续3天以上热议 + 股价稳步上涨:
|
||||
→ 趋势较强,可跟踪
|
||||
|
||||
突然冲上热议榜 + 股价暴涨:
|
||||
→ 情绪性炒作,风险较高
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 关键词云图
|
||||
|
||||
**功能说明**:
|
||||
提取社区帖子的高频关键词,直观展示当前市场关注焦点。
|
||||
|
||||
**关键词提取**:
|
||||
- 使用jieba分词
|
||||
- 过滤停用词
|
||||
- 统计词频
|
||||
- 生成词云可视化
|
||||
|
||||
**市场风格识别**:
|
||||
|
||||
通过关键词可以快速判断市场风格:
|
||||
|
||||
**科技成长风格**:
|
||||
```
|
||||
高频词:芯片、半导体、人工智能、新能源、锂电池
|
||||
→ 市场偏好科技成长股
|
||||
```
|
||||
|
||||
**价值防守风格**:
|
||||
```
|
||||
高频词:银行、地产、煤炭、分红、低估值
|
||||
→ 市场偏好价值股
|
||||
```
|
||||
|
||||
**题材炒作风格**:
|
||||
```
|
||||
高频词:妖股、涨停、龙头、超短线、打板
|
||||
→ 市场投机氛围浓厚
|
||||
```
|
||||
|
||||
**恐慌风格**:
|
||||
```
|
||||
高频词:暴跌、崩盘、止损、割肉、被套
|
||||
→ 市场恐慌,可能接近底部
|
||||
```
|
||||
|
||||
**使用场景**:
|
||||
|
||||
**场景1:识别热点板块**
|
||||
```
|
||||
观察到关键词:"芯片" "半导体" "国产替代"
|
||||
→ 半导体板块成为焦点
|
||||
→ 可关注板块内龙头股
|
||||
```
|
||||
|
||||
**场景2:判断市场情绪**
|
||||
```
|
||||
关键词大部分是负面词汇(暴跌、止损、割肉)
|
||||
→ 市场情绪悲观
|
||||
→ 可能是抄底机会
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 情绪与股价相关性
|
||||
|
||||
**功能说明**:
|
||||
分析特定股票的社区情绪与股价涨跌的相关性,验证是否为反向指标。
|
||||
|
||||
**相关系数解读**:
|
||||
|
||||
| 相关系数 | 关系 | 投资启示 |
|
||||
|----------|------|----------|
|
||||
| **0.7 ~ 1.0** | 强正相关 | 情绪高涨时股价上涨,同步指标 |
|
||||
| **0.3 ~ 0.7** | 中度正相关 | 有一定同步性 |
|
||||
| **-0.3 ~ 0.3** | 弱相关 | 情绪与股价关系不明显 |
|
||||
| **-0.7 ~ -0.3** | 中度负相关 | **反向指标**,情绪越乐观越要警惕 |
|
||||
| **-1.0 ~ -0.7** | 强负相关 | **典型反向指标** |
|
||||
|
||||
**典型案例**:
|
||||
|
||||
**案例1:茅台(强负相关 -0.65)**
|
||||
```
|
||||
现象:
|
||||
- 股价在1800元时,社区讨论热烈,乐观情绪爆棚
|
||||
- 股价在1500元时,社区骂声一片,极度悲观
|
||||
|
||||
结论:
|
||||
- 典型的反向指标
|
||||
- 情绪越乐观越要警惕顶部
|
||||
- 情绪越悲观越是买入机会
|
||||
|
||||
策略:
|
||||
- 当乐观比例 > 80% 时减仓
|
||||
- 当乐观比例 < 30% 时加仓
|
||||
```
|
||||
|
||||
**案例2:某妖股(强正相关 +0.78)**
|
||||
```
|
||||
现象:
|
||||
- 股价暴涨时,社区疯狂吹捧
|
||||
- 股价暴跌时,社区立刻转空
|
||||
|
||||
结论:
|
||||
- 追涨杀跌的投机股
|
||||
- 情绪和股价同步
|
||||
- 不适合做反向指标
|
||||
|
||||
策略:
|
||||
- 避免追高
|
||||
- 等待冷静后再介入
|
||||
```
|
||||
|
||||
**使用步骤**:
|
||||
1. 进入"社区情绪" → "情绪相关性"
|
||||
2. 输入股票代码(如600519)
|
||||
3. 点击"分析"
|
||||
4. 查看相关系数和双曲线图
|
||||
5. 根据相关性制定策略
|
||||
|
||||
---
|
||||
|
||||
### 5. 数据采集
|
||||
|
||||
**数据源**:
|
||||
- 东方财富股吧
|
||||
- 雪球
|
||||
- 其他社区(可扩展)
|
||||
|
||||
**采集内容**:
|
||||
- 帖子标题
|
||||
- 帖子内容
|
||||
- 评论数
|
||||
- 浏览量
|
||||
- 发布时间
|
||||
|
||||
**采集频率建议**:
|
||||
- **盘中**:每1-2小时采集一次
|
||||
- **盘后**:每天收盘后采集一次
|
||||
- **周末**:可暂停采集
|
||||
|
||||
**数据处理流程**:
|
||||
```
|
||||
1. 爬取帖子
|
||||
↓
|
||||
2. 去重(避免重复采集)
|
||||
↓
|
||||
3. 情绪分析(乐观/悲观/中性)
|
||||
↓
|
||||
4. 关键词提取
|
||||
↓
|
||||
5. 股票代码提取
|
||||
↓
|
||||
6. 存入数据库
|
||||
↓
|
||||
7. 计算情绪指数
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
|
||||
1. **反爬限制**:
|
||||
- 当前版本返回模拟数据
|
||||
- 实际生产需要:
|
||||
- 使用代理IP
|
||||
- 模拟浏览器headers
|
||||
- 控制请求频率
|
||||
- 处理验证码
|
||||
|
||||
2. **数据质量**:
|
||||
- 过滤广告和垃圾帖
|
||||
- 剔除机器人账号
|
||||
- 去除重复内容
|
||||
|
||||
3. **隐私合规**:
|
||||
- 仅采集公开内容
|
||||
- 不采集个人隐私信息
|
||||
- 遵守网站robots.txt规则
|
||||
|
||||
---
|
||||
|
||||
## 反向投资策略
|
||||
|
||||
### 策略1:情绪极值反转策略
|
||||
|
||||
**核心逻辑**:
|
||||
当情绪达到极值时,往往是反转点。
|
||||
|
||||
**操作规则**:
|
||||
|
||||
**买入信号**:
|
||||
```
|
||||
条件:
|
||||
1. 乐观比例 < 20%(极度悲观)
|
||||
2. 关键词以负面词为主(暴跌、割肉、崩盘)
|
||||
3. 大盘或个股已经大跌(如 -20%以上)
|
||||
|
||||
操作:
|
||||
- 分批建仓
|
||||
- 设置止损(如 -10%)
|
||||
- 耐心等待反弹
|
||||
```
|
||||
|
||||
**卖出信号**:
|
||||
```
|
||||
条件:
|
||||
1. 乐观比例 > 80%(极度乐观)
|
||||
2. 关键词以正面词为主(暴涨、牛市、万点)
|
||||
3. 大盘或个股已经大涨
|
||||
|
||||
操作:
|
||||
- 分批减仓
|
||||
- 落袋为安
|
||||
- 不追高
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 策略2:热议股票淘金策略
|
||||
|
||||
**核心逻辑**:
|
||||
热议股票早期可能是机会,后期可能是陷阱。
|
||||
|
||||
**操作规则**:
|
||||
|
||||
**早期介入**(机会):
|
||||
```
|
||||
条件:
|
||||
1. 讨论量开始上升(首次进入前20)
|
||||
2. 涨幅尚小(< 10%)
|
||||
3. 基本面或题材有催化剂
|
||||
|
||||
操作:
|
||||
- 小仓位试探
|
||||
- 设置止盈(如 +15%)
|
||||
- 密切跟踪
|
||||
```
|
||||
|
||||
**后期避让**(陷阱):
|
||||
```
|
||||
条件:
|
||||
1. 讨论量爆发(长时间霸榜前3)
|
||||
2. 涨幅已大(> 30%)
|
||||
3. 评论区一片乐观
|
||||
|
||||
操作:
|
||||
- 不追高
|
||||
- 如有持仓考虑减仓
|
||||
- 等待回调
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 策略3:关键词轮动策略
|
||||
|
||||
**核心逻辑**:
|
||||
跟随市场关注焦点,捕捉板块轮动。
|
||||
|
||||
**操作步骤**:
|
||||
|
||||
1. **每周查看关键词云**
|
||||
- 识别新出现的高频词
|
||||
- 判断是否是新热点
|
||||
|
||||
2. **对比上周关键词**
|
||||
- 哪些词热度上升(新热点)
|
||||
- 哪些词热度下降(退潮板块)
|
||||
|
||||
3. **布局新热点**
|
||||
- 在关键词刚开始高频时介入
|
||||
- 选择相关板块的龙头股
|
||||
|
||||
4. **退出退潮板块**
|
||||
- 关键词热度下降时减仓
|
||||
- 及时止盈
|
||||
|
||||
---
|
||||
|
||||
## 实战案例
|
||||
|
||||
### 案例1:2022年4月底部(极度悲观)
|
||||
|
||||
**情绪数据**:
|
||||
```
|
||||
乐观比例:15%
|
||||
悲观比例:68%
|
||||
热门关键词:暴跌、崩盘、割肉、套牢、熊市
|
||||
```
|
||||
|
||||
**市场表现**:
|
||||
- 上证指数跌至2863点
|
||||
- 社区一片哀嚎
|
||||
- "永不回本"成为热词
|
||||
|
||||
**反向操作**:
|
||||
- 分批买入指数基金
|
||||
- 买入超跌优质股
|
||||
|
||||
**结果**:
|
||||
- 随后2个月反弹20%+
|
||||
- 情绪指标成功抄底
|
||||
|
||||
---
|
||||
|
||||
### 案例2:2023年9月某科技股(讨论量激增)
|
||||
|
||||
**情绪数据**:
|
||||
```
|
||||
讨论量:从第50名冲到第3名
|
||||
涨幅:连续5天涨停
|
||||
评论区:全是"十倍股""抄底机会"
|
||||
```
|
||||
|
||||
**反向判断**:
|
||||
- 讨论量爆发 + 已经暴涨
|
||||
- 典型的追高情绪
|
||||
- 短期风险较大
|
||||
|
||||
**操作**:
|
||||
- 不追高
|
||||
- 等待回调后再观察
|
||||
|
||||
**结果**:
|
||||
- 随后3天连续跌停
|
||||
- 成功避开风险
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/sentiment/collect
|
||||
采集社区帖子
|
||||
|
||||
**参数**:
|
||||
- `limit`:每个来源采集数量(10-200)
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"collected": 100,
|
||||
"saved": 85
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/sentiment/index
|
||||
获取情绪指数
|
||||
|
||||
**参数**:
|
||||
- `date`:日期(可选,默认今天)
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"date": "2024-01-15",
|
||||
"bullish_count": 320,
|
||||
"bearish_count": 180,
|
||||
"neutral_count": 150,
|
||||
"bullish_ratio": 49.23,
|
||||
"total_posts": 650,
|
||||
"top_keywords": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/sentiment/hot_stocks
|
||||
热议股票排行
|
||||
|
||||
**参数**:
|
||||
- `days`:统计天数(1-7)
|
||||
- `limit`:返回数量(<=50)
|
||||
|
||||
### GET /api/sentiment/wordcloud
|
||||
关键词云
|
||||
|
||||
**参数**:
|
||||
- `days`:统计天数(1-30)
|
||||
- `top_n`:返回前N个关键词(<=100)
|
||||
|
||||
### GET /api/sentiment/correlation
|
||||
情绪与股价相关性
|
||||
|
||||
**参数**:
|
||||
- `code`:股票代码(必填)
|
||||
- `days`:分析天数(20-180)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据时效性**:
|
||||
- 情绪数据需要每日更新
|
||||
- 建议收盘后采集一次
|
||||
|
||||
2. **反向指标的局限性**:
|
||||
- 并非所有股票都是反向指标
|
||||
- 需要结合基本面和技术面
|
||||
- 极值信号出现后,反转可能需要时间
|
||||
|
||||
3. **样本量要求**:
|
||||
- 至少需要30天以上的数据
|
||||
- 数据点越多,相关性分析越准确
|
||||
|
||||
4. **隐私和合规**:
|
||||
- 仅采集公开信息
|
||||
- 不存储用户隐私
|
||||
- 遵守网站使用条款
|
||||
|
||||
5. **反爬应对**:
|
||||
- 生产环境需要配置代理
|
||||
- 控制请求频率
|
||||
- 模拟真实用户行为
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 接入更多社区数据源
|
||||
- [ ] 改进情绪分析算法(使用NLP模型)
|
||||
- [ ] 增加舆情热度趋势预警
|
||||
- [ ] 支持自定义情绪关键词库
|
||||
- [ ] 增加大V影响力分析
|
||||
- [ ] 实时情绪监控(WebSocket推送)
|
||||
- [ ] 情绪异常检测(突然转向)
|
||||
- [ ] 历史情绪回测(验证有效性)
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
社区情绪监控是一个**反向投资利器**,通过量化散户情绪,帮助投资者:
|
||||
|
||||
✅ **识别市场极值点**(极度乐观/悲观)
|
||||
✅ **发现热点和陷阱**(讨论量激增)
|
||||
✅ **把握板块轮动**(关键词变化)
|
||||
✅ **验证反向指标**(情绪与股价相关性)
|
||||
|
||||
**核心理念**:
|
||||
当别人恐惧时我贪婪,当别人贪婪时我恐惧。
|
||||
|
||||
**适合人群**:
|
||||
- 逆向思维投资者
|
||||
- 波段操作者
|
||||
- 价值投资者(择时参考)
|
||||
|
||||
**开始使用**:
|
||||
进入"社区情绪" → 点击"采集数据" → 查看情绪指数 → 制定反向策略!
|
||||
329
功能拓展/策略回测增强使用说明.md
Normal file
329
功能拓展/策略回测增强使用说明.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# 策略回测增强功能使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
策略回测增强模块提供了完整的量化回测能力,支持多因子策略、仓位管理、参数优化和策略对比。
|
||||
|
||||
## 四大功能模块
|
||||
|
||||
### 1. 快速回测
|
||||
**适用场景**:简单均线交叉策略的快速验证
|
||||
|
||||
**参数**:
|
||||
- 股票代码
|
||||
- 快线周期(如 5)
|
||||
- 慢线周期(如 20)
|
||||
|
||||
**输出指标**:
|
||||
- 策略收益率
|
||||
- 基准收益率(买入持有)
|
||||
- 超额收益
|
||||
- 最大回撤
|
||||
- 交易次数
|
||||
- 胜率
|
||||
|
||||
### 2. 高级回测
|
||||
**适用场景**:带仓位管理和风控的完整策略回测
|
||||
|
||||
**策略类型**:
|
||||
- **均线交叉**:MA 金叉死叉
|
||||
- **多因子**:技术(MA5/MA20)+ 动量(RSI)+ 资金(量比)
|
||||
|
||||
**高级参数**:
|
||||
- **仓位大小**(0-1):每次买入占可用资金比例
|
||||
- 1.0 = 满仓
|
||||
- 0.5 = 半仓
|
||||
- 0.3 = 三成仓位
|
||||
|
||||
- **止损比例**(%):跌破该比例自动卖出
|
||||
- 5 = 亏损 5% 止损
|
||||
- 0 = 不止损
|
||||
|
||||
- **止盈比例**(%):达到该比例自动卖出
|
||||
- 10 = 盈利 10% 止盈
|
||||
- 0 = 不止盈
|
||||
|
||||
**完整指标**:
|
||||
- 总收益率
|
||||
- 最大回撤
|
||||
- **夏普比率**(风险调整后收益,越高越好)
|
||||
- **卡玛比率**(收益/最大回撤,越高越好)
|
||||
- 交易次数 / 已平仓次数
|
||||
- 胜率
|
||||
- **盈亏比**(平均盈利/平均亏损)
|
||||
- 平均持仓天数
|
||||
|
||||
**交易明细**:
|
||||
- 每笔买入卖出记录
|
||||
- 持仓天数
|
||||
- 单笔盈亏和收益率
|
||||
- 买卖理由
|
||||
- 支持导出 CSV
|
||||
|
||||
### 3. 参数优化
|
||||
**适用场景**:寻找最优参数组合
|
||||
|
||||
**优化流程**:
|
||||
1. 定义参数网格
|
||||
- 快线范围:如 3,5,10,15
|
||||
- 慢线范围:如 20,30,60
|
||||
|
||||
2. 选择优化目标
|
||||
- 夏普比率(推荐,平衡收益和风险)
|
||||
- 总收益(追求最大收益)
|
||||
- 卡玛比率(追求稳定)
|
||||
|
||||
3. 一键优化
|
||||
- 自动测试所有参数组合
|
||||
- 按目标指标排序
|
||||
- 显示前 20 个最优结果
|
||||
|
||||
**结果展示**:
|
||||
- 参数组合排名
|
||||
- 目标指标值
|
||||
- 总收益、夏普比率、最大回撤、胜率
|
||||
|
||||
**示例**:
|
||||
```
|
||||
输入:
|
||||
- 股票:600519
|
||||
- 快线范围:3,5,10
|
||||
- 慢线范围:20,30,60
|
||||
- 优化目标:夏普比率
|
||||
|
||||
输出:
|
||||
排名1:快线5/慢线30,夏普2.15,收益45.2%,回撤12.3%
|
||||
排名2:快线10/慢线60,夏普1.98,收益38.7%,回撤9.8%
|
||||
...
|
||||
```
|
||||
|
||||
### 4. 策略对比
|
||||
**适用场景**:多个策略并排对比,找出最优策略
|
||||
|
||||
**预设对比**:
|
||||
1. MA5/20(无止损止盈)
|
||||
2. MA5/20(5% 止损,10% 止盈)
|
||||
3. MA10/30
|
||||
4. 多因子策略
|
||||
|
||||
**对比维度**:
|
||||
- 净值曲线(多条曲线叠加)
|
||||
- 总收益率(卡片并排显示)
|
||||
- 最大回撤
|
||||
- 夏普比率
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:快速验证均线策略
|
||||
```
|
||||
1. 点击"快速回测"
|
||||
2. 输入代码:600519
|
||||
3. 快线:5,慢线:20
|
||||
4. 点击"回测"
|
||||
5. 查看收益率和净值曲线
|
||||
```
|
||||
|
||||
### 示例 2:带止损止盈的策略
|
||||
```
|
||||
1. 点击"高级回测"
|
||||
2. 输入代码:600519
|
||||
3. 策略:均线交叉
|
||||
4. 快线:5,慢线:20
|
||||
5. 仓位:1.0(满仓)
|
||||
6. 止损:5%
|
||||
7. 止盈:10%
|
||||
8. 点击"高级回测"
|
||||
9. 查看完整指标和交易明细
|
||||
10. 点击"导出CSV"保存明细
|
||||
```
|
||||
|
||||
### 示例 3:参数优化寻找最优组合
|
||||
```
|
||||
1. 点击"参数优化"
|
||||
2. 输入代码:600519
|
||||
3. 快线范围:3,5,10,15
|
||||
4. 慢线范围:20,30,60
|
||||
5. 优化目标:夏普比率
|
||||
6. 点击"开始优化"
|
||||
7. 等待 10-30 秒(取决于组合数)
|
||||
8. 查看排名前 20 的参数组合
|
||||
9. 选择最优参数重新回测验证
|
||||
```
|
||||
|
||||
### 示例 4:策略对比
|
||||
```
|
||||
1. 点击"策略对比"
|
||||
2. 输入代码:600519
|
||||
3. 点击"对比策略"
|
||||
4. 查看 4 条净值曲线
|
||||
5. 对比各策略收益率
|
||||
6. 选择最优策略
|
||||
```
|
||||
|
||||
## 指标说明
|
||||
|
||||
### 夏普比率(Sharpe Ratio)
|
||||
- **含义**:风险调整后的收益
|
||||
- **公式**:(平均收益率 - 无风险利率) / 收益率标准差
|
||||
- **解读**:
|
||||
- \> 2.0:优秀
|
||||
- 1.0 - 2.0:良好
|
||||
- 0.5 - 1.0:一般
|
||||
- < 0.5:较差
|
||||
|
||||
### 卡玛比率(Calmar Ratio)
|
||||
- **含义**:收益率 / 最大回撤
|
||||
- **解读**:
|
||||
- \> 3.0:优秀
|
||||
- 2.0 - 3.0:良好
|
||||
- 1.0 - 2.0:一般
|
||||
- < 1.0:较差
|
||||
|
||||
### 最大回撤(Max Drawdown)
|
||||
- **含义**:从峰值到谷底的最大跌幅
|
||||
- **解读**:
|
||||
- < 10%:低风险
|
||||
- 10% - 20%:中等风险
|
||||
- 20% - 30%:较高风险
|
||||
- \> 30%:高风险
|
||||
|
||||
### 盈亏比(Profit Factor)
|
||||
- **含义**:平均盈利 / 平均亏损
|
||||
- **解读**:
|
||||
- \> 2.0:优秀
|
||||
- 1.5 - 2.0:良好
|
||||
- 1.0 - 1.5:一般
|
||||
- < 1.0:策略无效
|
||||
|
||||
## 策略说明
|
||||
|
||||
### 均线交叉策略(MA)
|
||||
**原理**:
|
||||
- 快线上穿慢线 → 买入(金叉)
|
||||
- 快线下穿慢线 → 卖出(死叉)
|
||||
|
||||
**适用市场**:趋势明确的单边市
|
||||
**不适用市场**:震荡市(易频繁止损)
|
||||
|
||||
### 多因子策略
|
||||
**买入条件**(需同时满足):
|
||||
- MA5 > MA20(趋势向上)
|
||||
- RSI < 70(未超买)
|
||||
- 量比 > 1.5(放量)
|
||||
|
||||
**卖出条件**(满足任一):
|
||||
- MA5 < MA20(趋势转弱)
|
||||
- RSI > 80(超买)
|
||||
|
||||
**适用市场**:震荡偏多市场
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/backtest/advanced
|
||||
高级回测
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"symbol": "600519",
|
||||
"strategy": "ma",
|
||||
"fast": 5,
|
||||
"slow": 20,
|
||||
"position_size": 1.0,
|
||||
"stop_loss": 5.0,
|
||||
"take_profit": 10.0,
|
||||
"initial_capital": 100000.0,
|
||||
"commission": 0.0005
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"symbol": "600519",
|
||||
"strategy": "MA5/20",
|
||||
"dates": ["2023-01-01", ...],
|
||||
"equity": [100000, 101200, ...],
|
||||
"bench": [100000, 100800, ...],
|
||||
"metrics": {
|
||||
"total_return": 45.23,
|
||||
"max_drawdown": 12.34,
|
||||
"sharpe_ratio": 2.15,
|
||||
"calmar_ratio": 3.67,
|
||||
"trades": 15,
|
||||
"win_rate": 66.7,
|
||||
"profit_factor": 2.3,
|
||||
"avg_hold_days": 12.5
|
||||
},
|
||||
"trades": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/backtest/optimize
|
||||
参数优化
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"symbol": "600519",
|
||||
"strategy": "ma",
|
||||
"fast_range": [3, 5, 10, 15],
|
||||
"slow_range": [20, 30, 60],
|
||||
"metric": "sharpe_ratio"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/backtest/compare
|
||||
策略对比
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"symbol": "600519",
|
||||
"strategies": [
|
||||
{"type": "ma", "fast": 5, "slow": 20},
|
||||
{"type": "ma", "fast": 5, "slow": 20, "stop_loss": 5, "take_profit": 10},
|
||||
{"type": "multi_factor"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据要求**:
|
||||
- 需要先在"数据中台"入库历史日线数据
|
||||
- 建议至少 1 年数据(250 个交易日)
|
||||
- 数据越多,回测越准确
|
||||
|
||||
2. **性能**:
|
||||
- 快速回测:1-2 秒
|
||||
- 高级回测:2-5 秒
|
||||
- 参数优化:10-60 秒(取决于组合数)
|
||||
- 策略对比:5-10 秒
|
||||
|
||||
3. **手续费**:
|
||||
- 默认 0.05%(双边 0.1%)
|
||||
- 可在高级参数中调整
|
||||
|
||||
4. **回测准确性**:
|
||||
- 基于收盘价成交(实际会有滑点)
|
||||
- 未考虑流动性限制
|
||||
- 未考虑冲击成本
|
||||
- 适合个人投资者小资金回测
|
||||
|
||||
5. **过拟合风险**:
|
||||
- 参数优化可能导致过拟合
|
||||
- 建议在不同市场环境下验证
|
||||
- 样本外测试很重要
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- [ ] 支持分钟线回测
|
||||
- [ ] 增加更多策略(MACD、KDJ、布林带)
|
||||
- [ ] 支持组合回测(多股票)
|
||||
- [ ] 仓位管理策略(凯利公式、风险平价)
|
||||
- [ ] 滚动回测(时间窗口滑动)
|
||||
- [ ] 蒙特卡洛模拟
|
||||
- [ ] 回测报告 PDF 导出
|
||||
</contents>
|
||||
434
功能拓展/财报深度解读使用说明.md
Normal file
434
功能拓展/财报深度解读使用说明.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# 财报深度解读使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
财报深度解读模块为技术分析提供基本面支持,通过关键指标趋势、AI摘要、同行对比、异常预警等功能,帮助投资者快速理解财报质量。
|
||||
|
||||
**核心理念**:技术分析看趋势,基本面分析看质量。两者结合,提高投资胜率。
|
||||
|
||||
---
|
||||
|
||||
## 五大核心功能
|
||||
|
||||
### 1. 财报关键指标趋势
|
||||
|
||||
**功能说明**:
|
||||
展示公司最近8个季度的核心财务指标变化趋势。
|
||||
|
||||
**核心指标**:
|
||||
|
||||
#### ROE(净资产收益率)
|
||||
- **定义**:净利润 / 净资产 × 100%
|
||||
- **意义**:衡量股东投资回报率
|
||||
- **优秀标准**:
|
||||
- ≥ 20%:优秀
|
||||
- 15-20%:良好
|
||||
- 10-15%:一般
|
||||
- < 10%:较差
|
||||
|
||||
**巴菲特标准**:长期ROE ≥ 15%是优质公司的重要标准。
|
||||
|
||||
#### 毛利率
|
||||
- **定义**:(营收 - 成本) / 营收 × 100%
|
||||
- **意义**:产品竞争力和定价权
|
||||
- **行业差异**:
|
||||
- 软件/医药:50%+
|
||||
- 消费品:30-50%
|
||||
- 制造业:20-30%
|
||||
- 零售业:10-20%
|
||||
|
||||
**趋势判断**:
|
||||
- 毛利率上升 → 竞争力增强
|
||||
- 毛利率下降 → 竞争加剧或成本上升
|
||||
|
||||
#### 营收增速
|
||||
- **定义**:(本期营收 - 去年同期营收) / 去年同期营收 × 100%
|
||||
- **意义**:公司成长性
|
||||
- **评判标准**:
|
||||
- ≥ 30%:高成长
|
||||
- 15-30%:稳健成长
|
||||
- 0-15%:低速成长
|
||||
- < 0%:负增长
|
||||
|
||||
#### 净利润增速
|
||||
- **定义**:(本期净利润 - 去年同期净利润) / 去年同期净利润 × 100%
|
||||
- **意义**:盈利能力变化
|
||||
- **与营收对比**:
|
||||
- 利润增速 > 营收增速 → 盈利能力提升
|
||||
- 利润增速 < 营收增速 → 盈利能力下降(警惕)
|
||||
|
||||
**使用步骤**:
|
||||
1. 进入"财报分析" → "指标趋势"
|
||||
2. 输入股票代码
|
||||
3. 点击"分析"
|
||||
4. 查看8个季度的指标变化
|
||||
5. 查看AI生成的一句话摘要
|
||||
|
||||
**实战案例**:
|
||||
```
|
||||
贵州茅台(600519)
|
||||
|
||||
ROE:25% → 稳定趋势
|
||||
毛利率:91% → 上升趋势
|
||||
营收增速:18% → 平稳趋势
|
||||
利润增速:19% → 上升趋势
|
||||
|
||||
AI摘要:业绩稳步增长,ROE保持25%高位,盈利能力优秀。
|
||||
|
||||
结论:优质公司,财务指标健康。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. AI财报摘要
|
||||
|
||||
**功能说明**:
|
||||
使用AI大模型自动生成一句话财报摘要(40字以内)。
|
||||
|
||||
**摘要要素**:
|
||||
- 业绩增长/下降趋势
|
||||
- 最亮眼或最担忧的指标
|
||||
- 简短评价(优秀/良好/一般/较差)
|
||||
|
||||
**示例摘要**:
|
||||
|
||||
```
|
||||
示例1(优秀):
|
||||
业绩大幅增长,ROE达30%创新高,毛利率稳定,盈利能力优秀。
|
||||
|
||||
示例2(一般):
|
||||
营收增长15%,但净利润下降5%,毛利率下滑,盈利质量下降。
|
||||
|
||||
示例3(较差):
|
||||
营收净利双降,ROE跌至8%,费用率上升,业绩承压明显。
|
||||
```
|
||||
|
||||
**价值**:
|
||||
- 快速了解财报核心信息
|
||||
- 节省阅读完整财报的时间
|
||||
- 适合快速筛选股票
|
||||
|
||||
**注意**:
|
||||
- 需要配置LLM_API_KEY
|
||||
- 未配置时显示简化版摘要
|
||||
|
||||
---
|
||||
|
||||
### 3. 同行对比
|
||||
|
||||
**功能说明**:
|
||||
将目标公司的财务指标与行业平均水平对比,识别优势和劣势。
|
||||
|
||||
**对比维度**:
|
||||
|
||||
| 指标 | 说明 | 优于行业的意义 |
|
||||
|------|------|----------------|
|
||||
| **ROE** | 盈利能力 | 投资回报更高 |
|
||||
| **毛利率** | 产品竞争力 | 定价权更强 |
|
||||
| **营收增速** | 成长性 | 市场份额扩大 |
|
||||
| **利润增速** | 盈利成长 | 利润增长更快 |
|
||||
| **资产负债率** | 财务风险 | 负债更低越好 |
|
||||
|
||||
**评分规则**:
|
||||
- 优于行业指标 ≥ 3个 → 优于行业
|
||||
- 优于行业指标 = 2个 → 持平行业
|
||||
- 优于行业指标 ≤ 1个 → 弱于行业
|
||||
|
||||
**实战案例**:
|
||||
|
||||
```
|
||||
茅台 vs 白酒行业
|
||||
|
||||
✓ ROE:25% vs 18%(+7%)
|
||||
✓ 毛利率:91% vs 75%(+16%)
|
||||
✓ 营收增速:18% vs 12%(+6%)
|
||||
✓ 利润增速:19% vs 10%(+9%)
|
||||
✗ 资产负债率:35% vs 32%(+3%)
|
||||
|
||||
优于行业指标:4/5
|
||||
综合结论:优于行业
|
||||
|
||||
解读:茅台在盈利能力、成长性方面显著优于行业,是白酒板块的绝对龙头。
|
||||
```
|
||||
|
||||
**使用技巧**:
|
||||
1. 先看综合结论
|
||||
2. 重点关注优于/弱于的具体指标
|
||||
3. 结合行业特点判断(如高负债率在金融业是正常的)
|
||||
|
||||
---
|
||||
|
||||
### 4. 财报异常预警
|
||||
|
||||
**功能说明**:
|
||||
自动检测财报中的5类常见异常,提前预警财务风险。
|
||||
|
||||
**5类异常**:
|
||||
|
||||
#### 异常1:存货激增
|
||||
- **判断标准**:存货增长 > 50%
|
||||
- **风险**:产品滞销,可能计提减值
|
||||
- **案例**:某消费股存货激增80%,半年后计提减值10亿
|
||||
|
||||
#### 异常2:应收账款占比过高
|
||||
- **判断标准**:应收账款 / 营收 > 50%
|
||||
- **风险**:回款压力大,可能出现坏账
|
||||
- **案例**:某科技股应收占比65%,次年确认坏账5亿
|
||||
|
||||
#### 异常3:毛利率大幅下降
|
||||
- **判断标准**:毛利率下降 > 5个百分点
|
||||
- **风险**:竞争力下降,盈利能力恶化
|
||||
- **案例**:某手机厂商毛利率从35%降至28%,股价腰斩
|
||||
|
||||
#### 异常4:资产负债率过高
|
||||
- **判断标准**:资产负债率 > 70%
|
||||
- **风险**:财务杠杆高,偿债风险大
|
||||
- **案例**:某地产股负债率85%,爆雷后退市
|
||||
|
||||
#### 异常5:增收不增利
|
||||
- **判断标准**:营收增长 > 10%,但净利润下降
|
||||
- **风险**:盈利质量差,成本失控
|
||||
- **案例**:某零售股营收增15%,净利润降20%,管理层被问责
|
||||
|
||||
**风险等级**:
|
||||
- **高风险**:有高严重度异常
|
||||
- **中风险**:有中严重度异常
|
||||
- **低风险**:无异常
|
||||
|
||||
**使用流程**:
|
||||
```
|
||||
1. 输入股票代码
|
||||
2. 点击"检测"
|
||||
3. 查看预警数量和风险等级
|
||||
4. 阅读具体异常描述
|
||||
5. 结合其他信息综合判断
|
||||
```
|
||||
|
||||
**应对策略**:
|
||||
|
||||
**高风险**:
|
||||
- 如有持仓,考虑减仓或清仓
|
||||
- 避免新进入
|
||||
- 等待公司解释或改善
|
||||
|
||||
**中风险**:
|
||||
- 密切关注后续财报
|
||||
- 适当降低仓位
|
||||
- 设置止损
|
||||
|
||||
**低风险**:
|
||||
- 财报质量良好
|
||||
- 可放心持有
|
||||
|
||||
---
|
||||
|
||||
### 5. 财报发布日历
|
||||
|
||||
**功能说明**:
|
||||
展示未来30天即将发布财报的股票,提前做好准备。
|
||||
|
||||
**使用场景**:
|
||||
|
||||
**场景1:持仓股票财报前准备**
|
||||
```
|
||||
如果持有的股票即将发布财报:
|
||||
- 业绩确定性高 → 继续持有
|
||||
- 业绩不确定 → 减仓避险
|
||||
- 业绩预期差 → 提前清仓
|
||||
```
|
||||
|
||||
**场景2:财报发布后介入**
|
||||
```
|
||||
关注即将发布财报的股票:
|
||||
- 业绩超预期 → 财报后1-3天买入
|
||||
- 业绩不及预期 → 避免
|
||||
```
|
||||
|
||||
**场景3:财报密集期策略**
|
||||
```
|
||||
财报密集发布期(1月、4月、7月、10月):
|
||||
- 整体风险偏好下降
|
||||
- 避免重仓单一股票
|
||||
- 等待业绩明朗后再操作
|
||||
```
|
||||
|
||||
**财报时间表**:
|
||||
- **一季报**:4月1日 - 4月30日
|
||||
- **半年报**:7月1日 - 8月31日
|
||||
- **三季报**:10月1日 - 10月31日
|
||||
- **年报**:1月1日 - 4月30日
|
||||
|
||||
---
|
||||
|
||||
## 财报排行榜
|
||||
|
||||
**功能说明**:
|
||||
按ROE、毛利率、营收增速等指标排名,快速找到财务优秀的公司。
|
||||
|
||||
**排行维度**:
|
||||
|
||||
**ROE排行**:
|
||||
- 找到最会赚钱的公司
|
||||
- 长期ROE > 20%的公司值得关注
|
||||
|
||||
**毛利率排行**:
|
||||
- 找到最有定价权的公司
|
||||
- 高毛利率公司抗风险能力强
|
||||
|
||||
**营收增速排行**:
|
||||
- 找到成长最快的公司
|
||||
- 高增速公司适合成长投资
|
||||
|
||||
**使用技巧**:
|
||||
1. 先看排行榜找到候选股票
|
||||
2. 再看指标趋势确认持续性
|
||||
3. 再看同行对比确认优势
|
||||
4. 最后看异常预警确认风险
|
||||
|
||||
---
|
||||
|
||||
## 综合使用流程
|
||||
|
||||
### 流程1:选股阶段
|
||||
|
||||
```
|
||||
1. 进入"财报排行" → 按ROE排序
|
||||
→ 找到ROE > 20%的股票(20只)
|
||||
|
||||
2. 逐个查看"指标趋势"
|
||||
→ 筛选ROE稳定或上升的股票(10只)
|
||||
|
||||
3. 查看"同行对比"
|
||||
→ 筛选优于行业的股票(5只)
|
||||
|
||||
4. 查看"异常预警"
|
||||
→ 排除有高风险预警的股票(3只)
|
||||
|
||||
5. 结合技术面分析
|
||||
→ 最终选定买入标的(1-2只)
|
||||
```
|
||||
|
||||
### 流程2:持仓检查
|
||||
|
||||
```
|
||||
每季度财报发布后:
|
||||
|
||||
1. 查看"指标趋势"
|
||||
→ ROE/毛利率是否下降?
|
||||
|
||||
2. 查看"AI摘要"
|
||||
→ 业绩是否符合预期?
|
||||
|
||||
3. 查看"异常预警"
|
||||
→ 是否出现新的风险?
|
||||
|
||||
4. 决策:
|
||||
- 指标恶化 + 异常预警 → 卖出
|
||||
- 指标平稳 + 无预警 → 继续持有
|
||||
- 指标改善 → 加仓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/financial/seed
|
||||
生成示例财报数据
|
||||
|
||||
### GET /api/financial/trend
|
||||
财报关键指标趋势
|
||||
|
||||
**参数**:
|
||||
- `code`:股票代码(必填)
|
||||
- `periods`:期数(4-16,默认8)
|
||||
|
||||
### GET /api/financial/summary
|
||||
AI财报摘要
|
||||
|
||||
**参数**:
|
||||
- `code`:股票代码(必填)
|
||||
|
||||
### GET /api/financial/compare
|
||||
同行对比
|
||||
|
||||
**参数**:
|
||||
- `code`:股票代码(必填)
|
||||
- `sector`:行业(可选)
|
||||
|
||||
### GET /api/financial/warnings
|
||||
财报异常预警
|
||||
|
||||
**参数**:
|
||||
- `code`:股票代码(必填)
|
||||
|
||||
### GET /api/financial/calendar
|
||||
财报发布日历
|
||||
|
||||
**参数**:
|
||||
- `days`:未来N天(7-90,默认30)
|
||||
|
||||
### GET /api/financial/rankings
|
||||
财报排行榜
|
||||
|
||||
**参数**:
|
||||
- `metric`:排序指标(roe/gross_margin/revenue_growth)
|
||||
- `limit`:返回数量(<=50,默认20)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据来源**:
|
||||
- 当前版本使用模拟数据演示
|
||||
- 实际生产需要接入Wind、同花顺等财报数据源
|
||||
|
||||
2. **财报时效性**:
|
||||
- 财报有滞后性(季报滞后1-2个月)
|
||||
- 结合最新公告和业绩预告
|
||||
|
||||
3. **行业差异**:
|
||||
- 不同行业指标标准不同
|
||||
- 同行对比更有参考价值
|
||||
|
||||
4. **综合判断**:
|
||||
- 财报分析只是一个维度
|
||||
- 需结合技术面、估值、政策等综合判断
|
||||
|
||||
5. **AI摘要限制**:
|
||||
- 需要配置LLM_API_KEY
|
||||
- AI摘要仅供参考,不构成投资建议
|
||||
|
||||
---
|
||||
|
||||
## 后续优化
|
||||
|
||||
- [ ] 接入真实财报数据源
|
||||
- [ ] 增加更多财务指标(现金流、ROA、ROIC等)
|
||||
- [ ] 财报对比(本期 vs 去年同期)
|
||||
- [ ] 财报预测(基于历史趋势)
|
||||
- [ ] 杜邦分析(ROE拆解)
|
||||
- [ ] 财报异常历史追踪
|
||||
- [ ] 财报发布前后股价统计
|
||||
- [ ] 支持批量筛选(按指标组合)
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
财报深度解读是**技术分析的重要补充**,通过基本面分析:
|
||||
|
||||
✅ **提高选股质量**(找到优质公司)
|
||||
✅ **降低投资风险**(识别财务陷阱)
|
||||
✅ **增强持股信心**(业绩支撑)
|
||||
✅ **把握买卖时机**(财报前后策略)
|
||||
|
||||
**核心价值**:
|
||||
让技术分析派也能快速理解财报,提高投资胜率。
|
||||
|
||||
**适合人群**:
|
||||
- 技术分析为主的投资者(补充基本面)
|
||||
- 价值投资者(快速筛选优质股)
|
||||
- 所有投资者(规避财务风险)
|
||||
|
||||
**开始使用**:
|
||||
进入"财报分析" → 点击"生成示例数据" → 输入股票代码 → 开始分析!
|
||||
45
待优化.md
Normal file
45
待优化.md
Normal file
@@ -0,0 +1,45 @@
|
||||
高优先级优化
|
||||
1. 缓存与性能优化
|
||||
akshare_service.py 使用内存缓存,服务重启后失效。建议引入 Redis 持久化缓存,避免频繁调用 AkShare API
|
||||
大量接口未做分页,如 /api/trades、/api/alerts/events 在数据量大时会有性能问题
|
||||
K线数据查询未添加索引优化提示
|
||||
2. 错误处理与容错
|
||||
多数接口缺少统一的异常捕获,数据源异常时可能返回 500 错误
|
||||
scheduler.py 定时任务失败后缺少重试机制和告警
|
||||
AkShare 数据源不稳定时,降级逻辑不够完善(只有 mock 数据)
|
||||
3. 数据完整性
|
||||
交易记录(trades)无事务一致性保证,买卖不匹配时持仓计算可能出错
|
||||
缺少数据修正接口,入库错误数据后无法手动修正
|
||||
历史数据回填(ingest_all)无断点续传,中断后需重新开始
|
||||
4. 安全性
|
||||
所有接口无鉴权,任何人可访问管理功能(/api/admin/*)
|
||||
环境变量敏感信息(LLM_API_KEY等)未加密存储
|
||||
CORS 配置为 allow_origins=["*"],生产环境不安全
|
||||
中优先级优化
|
||||
5. 功能增强
|
||||
AI 诊断结果无历史记录,无法追溯分析质量变化
|
||||
预警系统只支持价格/涨跌幅,缺少技术指标预警(如 MACD 金叉、RSI 超买等)
|
||||
复盘日报只能手动触发推送,未自动推送给用户
|
||||
自选股只支持单一列表,无分组管理功能
|
||||
6. 代码质量
|
||||
main.py 超过 500 行,建议拆分为多个 Router 模块
|
||||
缺少单元测试和集成测试
|
||||
部分函数职责不清晰,如 _gen_review_text() 混合了业务逻辑和文案生成
|
||||
7. 监控与日志
|
||||
缺少接口访问日志和性能监控
|
||||
定时任务执行日志只保留在数据库,无实时监控面板
|
||||
AkShare 调用频率无监控,可能触发限流
|
||||
低优先级优化
|
||||
8. 用户体验
|
||||
前端刷新后丢失自选股筛选条件
|
||||
K线图无技术指标叠加功能(如 MACD、KDJ)
|
||||
移动端适配不足
|
||||
9. 扩展性
|
||||
只支持 A 股,缺少港股、美股扩展能力
|
||||
数据源绑定 AkShare,切换成本高
|
||||
多组合管理需要大幅重构 portfolio.py
|
||||
建议优先实现的 3 个功能
|
||||
Redis 缓存层 - 提升响应速度,减少 API 调用
|
||||
统一异常处理中间件 - 提升系统稳定性
|
||||
基础鉴权机制 - 保护管理接口安全
|
||||
需要我深入分析某个模块的优化方案吗?
|
||||
Reference in New Issue
Block a user