This commit is contained in:
liao 2025-05-14 08:54:56 +08:00
parent aadef5f0fa
commit 0920615bbd
40 changed files with 3955 additions and 253 deletions

17
.dockerignore Normal file
View File

@ -0,0 +1,17 @@
.git
.gitignore
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
venv
.env
*.log
logs/
reports/
.idea
.vscode
*.swp
*.swo

52
Dockerfile Normal file
View File

@ -0,0 +1,52 @@
# 使用Python 3.9作为基础镜像
FROM python:3.9-slim
# 设置工作目录
WORKDIR /app
# 设置环境变量
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV TZ=Asia/Shanghai
# 使用阿里云源替换默认的 Debian 源 (Debian 12 - Bookworm)
RUN echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list
# 安装系统依赖和中文字体
RUN apt-get update && apt-get install -y \
build-essential \
tzdata \
fonts-wqy-microhei \
fonts-wqy-zenhei \
xfonts-wqy \
ttf-wqy-microhei \
ttf-wqy-zenhei \
&& rm -rf /var/lib/apt/lists/*
# 创建字体目录
RUN mkdir -p /app/src/fundamentals_llm/fonts
# 复制中文字体到应用目录
RUN cp /usr/share/fonts/truetype/wqy/wqy-microhei.ttc /app/src/fundamentals_llm/fonts/simhei.ttf || \
cp /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/src/fundamentals_llm/fonts/simhei.ttf || \
cp /usr/share/fonts/wqy-microhei/wqy-microhei.ttc /app/src/fundamentals_llm/fonts/simhei.ttf || \
cp /usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc /app/src/fundamentals_llm/fonts/simhei.ttf || echo "Warning: Could not find WQY font, PDF generation may fail"
# 复制项目文件
COPY requirements.txt .
COPY src/ ./src/
# 安装Python依赖使用阿里云源
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 创建必要的目录
RUN mkdir -p /app/logs /app/reports
# 暴露端口
EXPOSE 5000
# 设置启动命令
CMD ["python", "src/app.py"]

43
data.sql Normal file
View File

@ -0,0 +1,43 @@
CREATE TABLE `gp_day_data` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`symbol` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '个股代码',
`timestamp` timestamp NULL DEFAULT NULL COMMENT '时间戳',
`volume` bigint NULL DEFAULT NULL COMMENT '数量',
`open` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '开始价',
`high` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '最高价',
`low` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '最低价',
`close` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '结束价',
`chg` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '变化数值',
`percent` decimal(10, 2) NULL DEFAULT NULL COMMENT '变化百分比',
`turnoverrate` decimal(10, 2) NULL DEFAULT NULL COMMENT '换手率',
`amount` bigint NULL DEFAULT NULL COMMENT '成交金额',
`pb` decimal(10, 2) NULL DEFAULT NULL COMMENT '当前PB',
`pe` decimal(10, 2) NULL DEFAULT NULL COMMENT '当前PE',
`ps` decimal(10, 2) NULL DEFAULT NULL COMMENT '当前PS',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_symbol`(`symbol` ASC) USING BTREE,
INDEX `idx_timestamp`(`timestamp` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 20472590 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
CREATE TABLE `gp_gnbk` (
`id` bigint NULL DEFAULT NULL,
`bk_code` bigint NULL DEFAULT NULL,
`bk_name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
`gp_code` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
`gp_name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for gp_hybk
-- ----------------------------
DROP TABLE IF EXISTS `gp_hybk`;
CREATE TABLE `gp_hybk` (
`id` bigint NULL DEFAULT NULL,
`bk_code` bigint NULL DEFAULT NULL,
`bk_name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
`gp_code` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
`gp_name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;

29
deploy-compose.sh Normal file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# 检查参数
if [ "$#" -lt 1 ]; then
echo "使用方法: $0 <实例数量>"
exit 1
fi
# 实例数量
NUM_INSTANCES=$1
BASE_PORT=5088 # 从5088开始
# 构建基础镜像
echo "构建基础镜像..."
docker-compose build
# 部署新实例
echo "开始部署 $NUM_INSTANCES 个实例..."
for ((i=1; i<=$NUM_INSTANCES; i++))
do
PROJECT_NAME="stock-app-$i"
PORT=$((BASE_PORT + i - 1)) # 从5088开始递增
echo "部署实例 $i: $PROJECT_NAME 在端口 $PORT"
PORT=$PORT docker-compose -p $PROJECT_NAME up -d
done
echo "全部实例已部署完成!"
echo "运行 'docker ps' 查看所有实例状态"

52
deploy-multiple.sh Normal file
View File

@ -0,0 +1,52 @@
#!/bin/bash
# 检查参数
if [ "$#" -lt 1 ]; then
echo "使用方法: $0 <实例数量>"
exit 1
fi
# 实例数量
NUM_INSTANCES=$1
BASE_PORT=5088 # 从5088开始
# 构建基础镜像
echo "构建基础镜像..."
docker-compose build
# 停止并删除旧容器
echo "清理旧容器..."
for ((i=1; i<=$NUM_INSTANCES; i++))
do
INSTANCE_NAME="stock-app-$i"
docker stop $INSTANCE_NAME 2>/dev/null
docker rm $INSTANCE_NAME 2>/dev/null
done
# 部署新实例
echo "开始部署 $NUM_INSTANCES 个实例..."
for ((i=1; i<=$NUM_INSTANCES; i++))
do
INSTANCE_NAME="stock-app-$i"
PORT=$((BASE_PORT + i - 1)) # 从5088开始递增
DATA_DIR="./instances/instance-$i"
# 创建实例目录
mkdir -p "$DATA_DIR/logs"
mkdir -p "$DATA_DIR/reports"
echo "部署实例 $i: $INSTANCE_NAME 在端口 $PORT"
docker run -d \
--name $INSTANCE_NAME \
-p $PORT:5000 \
-v "$DATA_DIR/logs:/app/logs" \
-v "$DATA_DIR/reports:/app/reports" \
-e "FLASK_ENV=production" \
-e "FLASK_APP=src/app.py" \
-e "INSTANCE_ID=$i" \
--restart unless-stopped \
gpfx-app:latest
done
echo "全部实例已部署完成!"
echo "运行 'docker ps' 查看所有实例状态"

19
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,19 @@
version: '3'
services:
app:
build: .
ports:
- "5000:5000"
volumes:
- ./logs:/app/logs
- ./reports:/app/reports
environment:
- FLASK_ENV=production
- FLASK_APP=src/app.py
restart: always
deploy:
resources:
limits:
cpus: '1'
memory: 1G

30
docker-compose.swarm.yml Normal file
View File

@ -0,0 +1,30 @@
version: '3.8'
services:
app:
image: gpfx-app:latest
build: .
ports:
- "5000:5000"
volumes:
- logs:/app/logs
- reports:/app/reports
environment:
- FLASK_ENV=production
- FLASK_APP=src/app.py
deploy:
mode: replicated
replicas: 3
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
resources:
limits:
cpus: '0.5'
memory: 512M
volumes:
logs:
reports:

19
docker-compose.yml Normal file
View File

@ -0,0 +1,19 @@
version: '3'
services:
app:
build: .
ports:
- "${PORT:-5000}:5000"
volumes:
- ./logs:/app/logs
- ./reports:/app/reports
environment:
- FLASK_ENV=production
- FLASK_APP=src/app.py
restart: always
deploy:
resources:
limits:
cpus: '1'
memory: 1G

218
manage-instances.sh Normal file
View File

@ -0,0 +1,218 @@
#!/bin/bash
# 函数:显示帮助信息
show_help() {
echo "股票基础分析应用管理脚本"
echo ""
echo "用法: $0 [命令] [参数]"
echo ""
echo "命令:"
echo " list 列出所有运行的实例"
echo " start [实例ID] 启动指定实例或所有实例"
echo " stop [实例ID] 停止指定实例或所有实例"
echo " restart [实例ID] 重启指定实例或所有实例"
echo " logs [实例ID] 查看指定实例的日志"
echo " status 显示实例状态概览"
echo " remove [实例ID] 删除指定实例或所有实例"
echo " rebuild [数量] 重新构建镜像并部署指定数量的实例"
echo " update 热更新代码不重建镜像仅复制src目录"
echo ""
echo "示例:"
echo " $0 list 列出所有实例"
echo " $0 start 2 启动实例2"
echo " $0 stop all 停止所有实例"
echo " $0 logs 1 查看实例1的日志"
echo " $0 rebuild 2 重新构建并部署2个实例"
echo " $0 update 热更新所有实例的代码"
exit 1
}
# 函数:列出所有实例
list_instances() {
echo "正在运行的实例:"
docker ps --filter "name=stock-app-" --format "表格 {{.Names}}\t{{.Status}}\t{{.Ports}}"
}
# 函数:启动实例
start_instance() {
if [ "$1" = "all" ]; then
echo "启动所有实例..."
docker start $(docker ps -a --filter "name=stock-app-" -q)
else
echo "启动实例 $1..."
docker start stock-app-$1
fi
}
# 函数:停止实例
stop_instance() {
if [ "$1" = "all" ]; then
echo "停止所有实例..."
docker stop $(docker ps --filter "name=stock-app-" -q)
else
echo "停止实例 $1..."
docker stop stock-app-$1
fi
}
# 函数:重启实例
restart_instance() {
if [ "$1" = "all" ]; then
echo "重启所有实例..."
docker restart $(docker ps --filter "name=stock-app-" -q)
else
echo "重启实例 $1..."
docker restart stock-app-$1
fi
}
# 函数:查看实例日志
view_logs() {
echo "实例 $1 的日志:"
docker logs stock-app-$1
}
# 函数:显示状态概览
show_status() {
echo "实例状态概览:"
echo "-----------------"
echo "实例总数: $(docker ps -a --filter "name=stock-app-" --format "{{.Names}}" | wc -l)"
echo "运行中: $(docker ps --filter "name=stock-app-" --format "{{.Names}}" | wc -l)"
echo "已停止: $(docker ps -a --filter "name=stock-app-" --filter "status=exited" --format "{{.Names}}" | wc -l)"
echo "-----------------"
docker ps -a --filter "name=stock-app-" --format "表格 {{.Names}}\t{{.Status}}\t{{.Ports}}"
}
# 函数:删除实例
remove_instance() {
if [ "$1" = "all" ]; then
echo "删除所有实例..."
docker rm -f $(docker ps -a --filter "name=stock-app-" -q)
else
echo "删除实例 $1..."
docker rm -f stock-app-$1
fi
}
# 函数:重新构建并部署
rebuild_instances() {
NUM_INSTANCES=$1
echo "开始全面重建流程..."
# 1. 停止所有实例
echo "停止所有实例..."
docker stop $(docker ps --filter "name=stock-app-" -q) 2>/dev/null
# 2. 删除所有实例
echo "删除所有实例..."
docker rm -f $(docker ps -a --filter "name=stock-app-" -q) 2>/dev/null
# 3. 重新构建镜像
echo "重新构建镜像..."
docker-compose build
# 4. 部署新实例
echo "部署 $NUM_INSTANCES 个新实例..."
./deploy-multiple.sh $NUM_INSTANCES
echo "重建完成!"
}
# 函数:热更新代码
update_code() {
# 获取所有正在运行的实例
RUNNING_INSTANCES=$(docker ps --filter "name=stock-app-" -q)
if [ -z "$RUNNING_INSTANCES" ]; then
echo "没有发现正在运行的实例!"
return 1
fi
echo "发现 $(echo "$RUNNING_INSTANCES" | wc -l) 个实例需要更新..."
# 更新每个实例的代码
for CONTAINER_ID in $RUNNING_INSTANCES; do
CONTAINER_NAME=$(docker inspect --format='{{.Name}}' $CONTAINER_ID | sed 's/\///')
echo "更新 $CONTAINER_NAME 的代码..."
# 复制 src 目录到容器
docker cp ./src/. $CONTAINER_ID:/app/src/
# 重启 Flask 应用如果使用的是gunicorn等进程管理器需要向进程发送信号
echo "重启 $CONTAINER_NAME 中的应用..."
docker exec $CONTAINER_ID sh -c "pkill -f 'python src/app.py' && nohup python src/app.py > /app/logs/app.log 2>&1 &"
done
echo "所有实例代码已更新,并尝试重启应用!"
echo "注意:如果应用启动方式不是 'python src/app.py',可能需要手动重启容器:"
echo "./manage-instances.sh restart all"
}
# 主逻辑
if [ "$#" -lt 1 ]; then
show_help
fi
case "$1" in
list)
list_instances
;;
start)
if [ "$#" -lt 2 ]; then
echo "错误: 缺少实例ID参数"
show_help
fi
start_instance $2
;;
stop)
if [ "$#" -lt 2 ]; then
echo "错误: 缺少实例ID参数"
show_help
fi
stop_instance $2
;;
restart)
if [ "$#" -lt 2 ]; then
echo "错误: 缺少实例ID参数"
show_help
fi
restart_instance $2
;;
logs)
if [ "$#" -lt 2 ]; then
echo "错误: 缺少实例ID参数"
show_help
fi
view_logs $2
;;
status)
show_status
;;
remove)
if [ "$#" -lt 2 ]; then
echo "错误: 缺少实例ID参数"
show_help
fi
remove_instance $2
;;
rebuild)
if [ "$#" -lt 2 ]; then
echo "错误: 缺少实例数量参数"
show_help
fi
rebuild_instances $2
;;
update)
update_code
;;
help|--help|-h)
show_help
;;
*)
echo "错误: 未知命令 '$1'"
show_help
;;
esac
exit 0

View File

@ -1,7 +1,7 @@
flask==2.0.3 flask==2.0.3
werkzeug==2.0.3 werkzeug==2.0.3
flask-cors==3.0.10 flask-cors==3.0.10
sqlalchemy==1.4.46 sqlalchemy==2.0.40
pymysql==1.0.3 pymysql==1.0.3
tqdm>=4.65.0 tqdm>=4.65.0
easy-spider-tool>=0.0.4 easy-spider-tool>=0.0.4
@ -15,3 +15,4 @@ reportlab>=4.3.1
markdown2>=2.5.3 markdown2>=2.5.3
google-genai google-genai
redis==5.2.1 redis==5.2.1
pandas==2.2.3

22
run_backtest.bat Normal file
View File

@ -0,0 +1,22 @@
@echo off
echo 股票回测工具启动中...
REM 检查是否提供了参数
if "%~1"=="" (
echo 错误: 请提供输入CSV文件路径
echo 用法: run_backtest.bat 输入文件.csv 结束日期
echo 例如: run_backtest.bat data\sample_portfolio.csv 2023-08-28
exit /b 1
)
if "%~2"=="" (
echo 错误: 请提供结束日期 (YYYY-MM-DD格式)
echo 用法: run_backtest.bat 输入文件.csv 结束日期
echo 例如: run_backtest.bat data\sample_portfolio.csv 2023-08-28
exit /b 1
)
REM 运行Python脚本
python src\run_backtest.py --input "%~1" --end-date "%~2"
echo 回测结束!

23
run_backtest.sh Normal file
View File

@ -0,0 +1,23 @@
#!/bin/bash
echo "股票回测工具启动中..."
# 检查是否提供了参数
if [ -z "$1" ]; then
echo "错误: 请提供输入CSV文件路径"
echo "用法: ./run_backtest.sh 输入文件.csv 结束日期"
echo "例如: ./run_backtest.sh data/sample_portfolio.csv 2023-08-28"
exit 1
fi
if [ -z "$2" ]; then
echo "错误: 请提供结束日期 (YYYY-MM-DD格式)"
echo "用法: ./run_backtest.sh 输入文件.csv 结束日期"
echo "例如: ./run_backtest.sh data/sample_portfolio.csv 2023-08-28"
exit 1
fi
# 运行Python脚本
python src/run_backtest.py --input "$1" --end-date "$2"
echo "回测结束!"

View File

@ -357,6 +357,155 @@
} }
``` ```
### 9. 股票估值分析接口
获取股票的PE/PB估值分析数据支持与行业和概念板块平均值的对比。
- **URL**: `/api/valuation_analysis`
- **方法**: `GET`
- **参数**:
- `stock_code`: 股票代码和stock_name二选一
- `stock_name`: 股票名称和stock_code二选一目前暂不支持
- `start_date`: 开始日期可选默认为2018-01-01格式为YYYY-MM-DD
- `industry_name`: 行业名称(可选)
- `concept_name`: 概念板块名称(可选)
- `metric`: 估值指标,可选值为'pe'或'pb',默认为'pe'
- **响应示例**:
```json
{
"status": "success",
"data": {
"title": {
"text": "SH601138 工业富联 历史PE分位数分析",
"subtext": "当前PE百分位: 27.39%"
},
"tooltip": {
"trigger": "axis",
"axisPointer": {
"type": "cross"
}
},
"legend": {
"data": ["工业富联 PE", "外骨骼机器人概念平均PE"]
},
"grid": {
"left": "3%",
"right": "4%",
"bottom": "3%",
"containLabel": true
},
"xAxis": {
"type": "category",
"boundaryGap": false,
"data": ["2023-01-01", "2023-01-02", ..., "2023-12-31"]
},
"yAxis": {
"type": "value",
"name": "PE值"
},
"series": [
{
"name": "工业富联 PE",
"type": "line",
"data": [15.23, 15.45, ..., 15.30],
"markLine": {
"data": [
{"name": "最小值", "yAxis": 8.84},
{"name": "最大值", "yAxis": 27.15},
{"name": "均值", "yAxis": 18.03},
{"name": "第一四分位数", "yAxis": 12.47},
{"name": "第三四分位数", "yAxis": 22.35},
{"name": "当前值", "yAxis": 15.30}
]
}
},
{
"name": "外骨骼机器人概念平均PE",
"type": "line",
"data": [["2023-01-01", 25.67], ["2023-01-02", 26.12], ..., ["2023-12-31", 28.45]],
"lineStyle": {
"color": "#EE6666"
}
}
],
"percentiles": {
"min": 8.84,
"max": 27.15,
"current": 15.30,
"mean": 18.03,
"median": 17.25,
"q1": 12.47,
"q3": 22.35,
"percentile": 27.39
},
"concept_stats": {
"name": "外骨骼机器人",
"min_count": 5,
"max_count": 8,
"avg_count": 6.5,
"avg_value": 26.78
}
}
}
```
- **错误响应**:
```json
{
"status": "error",
"message": "未找到股票 SH000001 的历史数据"
}
```
#### 使用示例
##### 基本使用 - 获取单只股票PE估值分析
```bash
curl -X GET "http://localhost:5000/api/valuation_analysis?stock_code=SH601138&metric=pe"
```
##### 自定义开始日期
```bash
curl -X GET "http://localhost:5000/api/valuation_analysis?stock_code=SH601138&start_date=2022-01-01&metric=pe"
```
##### 添加行业对比
```bash
curl -X GET "http://localhost:5000/api/valuation_analysis?stock_code=SH601138&industry_name=电子设备&metric=pe"
```
##### 添加概念板块对比
```bash
curl -X GET "http://localhost:5000/api/valuation_analysis?stock_code=SH601138&concept_name=外骨骼机器人&metric=pe"
```
##### 同时对比行业和概念板块
```bash
curl -X GET "http://localhost:5000/api/valuation_analysis?stock_code=SH601138&industry_name=电子设备&concept_name=外骨骼机器人&metric=pe"
```
##### 分析PB估值
```bash
curl -X GET "http://localhost:5000/api/valuation_analysis?stock_code=SH601138&metric=pb"
```
#### 接口说明
1. 返回的数据格式完全符合ECharts的配置需求可以直接用于前端图表展示。
2. `percentiles`对象包含了所有分位数统计信息,便于前端展示。
3. 如果提供了`industry_name`或`concept_name`,接口会返回相应的行业或概念板块平均估值数据。
4. 行业和概念板块的统计信息包含在`industry_stats`和`concept_stats`对象中,包括成分股数量和平均值等。
5. 所有的数据都已经过滤了异常值(PE/PB > 1000),确保图表的可视化效果。
#### 前端ECharts使用示例
```javascript
// 假设已通过AJAX获取到API返回的数据并存储在response变量中
const chartData = response.data;
const chart = echarts.init(document.getElementById('main'));
chart.setOption(chartData);
```
## 错误处理 ## 错误处理
当发生错误时API 将返回带有错误详情的 JSON 响应: 当发生错误时API 将返回带有错误详情的 JSON 响应:
@ -830,3 +979,4 @@ curl -X POST http://localhost:5000/api/comprehensive_analysis \
} }
}' }'
``` ```

View File

@ -0,0 +1,83 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
股票估值分析入口脚本
直接运行此脚本可以分析指定股票的PE/PB历史分位数
"""
import sys
import os
import argparse
from valuation_analysis.pe_pb_analysis import ValuationAnalyzer
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="股票PE/PB估值分析工具")
parser.add_argument('--stock', '-s', type=str, required=True,
help='股票代码例如601138')
parser.add_argument('--start-date', type=str, default='2018-01-01',
help='起始日期 (默认: 2018-01-01)')
parser.add_argument('--metrics', type=str, default='pe,pb',
help='分析指标,用逗号分隔 (默认: pe,pb)')
return parser.parse_args()
def main():
"""主函数"""
args = parse_args()
# 解析参数
stock_code = args.stock
start_date = args.start_date
metrics = args.metrics.split(',')
print(f"开始分析股票 {stock_code} 的估值情况...")
# 创建分析器并运行分析
analyzer = ValuationAnalyzer()
result = analyzer.analyze_stock_valuation(stock_code, start_date, metrics)
# 输出结果
if not result['success']:
print(f"分析失败: {result.get('message', '未知错误')}")
return 1
# 打印分析结果
stock_name = result['stock_name']
analysis_date = result['analysis_date']
print("\n" + "="*50)
print(f"股票代码: {stock_code}")
print(f"股票名称: {stock_name}")
print(f"分析日期: {analysis_date}")
print("="*50)
for metric in result['metrics']:
metric_data = result['metrics'][metric]
metric_name = "PE" if metric == "pe" else "PB"
print(f"\n{metric_name}分析结果:")
print("-"*30)
print(f"当前{metric_name}: {metric_data['current']:.2f}")
print(f"{metric_name}百分位: {metric_data['percentile']:.2f}%")
print(f"历史最小值: {metric_data['min']:.2f}")
print(f"历史最大值: {metric_data['max']:.2f}")
print(f"历史均值: {metric_data['mean']:.2f}")
print(f"历史中位数: {metric_data['median']:.2f}")
print(f"第一四分位数: {metric_data['q1']:.2f}")
print(f"第三四分位数: {metric_data['q3']:.2f}")
print(f"估值曲线图: {metric_data['chart_path']}")
print("\n" + "="*50)
print(f"分析完成图表已保存到results/valuation_analysis/目录下")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -5,13 +5,13 @@ import pandas as pd
import uuid import uuid
import json import json
from threading import Thread from threading import Thread
from sqlalchemy import create_engine, text
from src.fundamentals_llm.fundamental_analysis_database import get_analysis_result, get_db from src.fundamentals_llm.fundamental_analysis_database import get_analysis_result, get_db
# 添加项目根目录到 Python 路径 # 添加项目根目录到 Python 路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from flask import Flask, jsonify, request, send_from_directory from flask import Flask, jsonify, request, send_from_directory, render_template
from flask_cors import CORS from flask_cors import CORS
import logging import logging
@ -21,6 +21,12 @@ from src.fundamentals_llm.enterprise_screener import EnterpriseScreener
# 导入股票回测器 # 导入股票回测器
from src.stock_analysis_v2 import run_backtest, StockBacktester from src.stock_analysis_v2 import run_backtest, StockBacktester
# 导入PE/PB估值分析器
from src.valuation_analysis.pe_pb_analysis import ValuationAnalyzer
# 导入行业估值分析器
from src.valuation_analysis.industry_analysis import IndustryAnalyzer
# 设置日志 # 设置日志
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -47,8 +53,11 @@ CORS(app) # 启用跨域请求支持
# 创建企业筛选器实例 # 创建企业筛选器实例
screener = EnterpriseScreener() screener = EnterpriseScreener()
# 获取数据库连接 # 创建估值分析器实例
db = next(get_db()) valuation_analyzer = ValuationAnalyzer()
# 创建行业分析器实例
industry_analyzer = IndustryAnalyzer()
# 获取项目根目录 # 获取项目根目录
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -141,6 +150,11 @@ def run_backtest_task(task_id, stocks_buy_dates, end_date):
backtest_tasks[task_id]['error'] = str(e) backtest_tasks[task_id]['error'] = str(e)
logger.error(f"回测任务 {task_id} 失败:{str(e)}") logger.error(f"回测任务 {task_id} 失败:{str(e)}")
@app.route('/')
def index():
"""渲染主页"""
return render_template('index.html')
@app.route('/api/backtest/run', methods=['POST']) @app.route('/api/backtest/run', methods=['POST'])
def start_backtest(): def start_backtest():
"""启动回测任务 """启动回测任务
@ -854,7 +868,6 @@ def analyze_and_recommend():
"message": f"分析和推荐股票失败: {str(e)}" "message": f"分析和推荐股票失败: {str(e)}"
}), 500 }), 500
@app.route('/api/comprehensive_analysis', methods=['POST']) @app.route('/api/comprehensive_analysis', methods=['POST'])
def comprehensive_analysis(): def comprehensive_analysis():
"""综合分析接口 - 使用队列方式处理被锁定的股票 """综合分析接口 - 使用队列方式处理被锁定的股票
@ -967,24 +980,6 @@ def comprehensive_analysis():
# 处理当前队列中的所有股票 # 处理当前队列中的所有股票
for stock_code, stock_name in processing_queue: for stock_code, stock_name in processing_queue:
try: try:
# 检查是否已有分析结果
db = next(get_db())
existing_result = get_analysis_result(db, stock_code, "investment_advice")
# 如果已有近期结果,直接使用
if existing_result and existing_result.update_time > datetime.now() - timedelta(hours=12):
investment_advices[stock_code] = {
"code": stock_code,
"name": stock_name,
"advice": existing_result.ai_response,
"reasoning": existing_result.reasoning_process,
"references": existing_result.references,
"status": "success",
"from_cache": True
}
logger.info(f"使用缓存的 {stock_name}({stock_code}) 分析结果")
continue
# 检查是否被锁定 # 检查是否被锁定
if analyzer.is_stock_locked(stock_code, "investment_advice"): if analyzer.is_stock_locked(stock_code, "investment_advice"):
# 已被锁定,放到下一轮队列 # 已被锁定,放到下一轮队列
@ -1175,14 +1170,42 @@ def comprehensive_analysis():
# 获取传入的所有股票代码 # 获取传入的所有股票代码
input_stock_codes = set(code for code, _ in stocks) input_stock_codes = set(code for code, _ in stocks)
# 查询赛道关联信息
track_query = text("""
SELECT DISTINCT pc.stock_code, ci.belong_industry
FROM gp_product_category pc
JOIN gp_category_industry ci ON pc.category_name = ci.category_name
WHERE pc.stock_code IN :stock_codes
""")
# 获取赛道信息
track_results = {}
try:
# 获取数据库连接
db_session2 = next(get_db())
# 使用 Session 的 execute 方法执行查询
result = db_session2.execute(track_query, {"stock_codes": tuple(input_stock_codes)})
for row in result:
if row.stock_code not in track_results:
track_results[row.stock_code] = []
if row.belong_industry: # 确保 belong_industry 不为空
track_results[row.stock_code].append(row.belong_industry)
except Exception as e:
logger.error(f"查询赛道信息失败: {str(e)}")
track_results = {}
finally:
if 'db_session2' in locals() and db_session2 is not None: # 确保 db_session 已定义
db_session2.close() # <--- 关闭会话
db_session = next(get_db())
# 筛选出传入列表中符合条件的股票 # 筛选出传入列表中符合条件的股票
for code, name in all_stocks: for code, name in all_stocks:
if code in input_stock_codes: if code in input_stock_codes:
# 获取各个维度的分析结果 # 获取各个维度的分析结果
investment_advice_result = get_analysis_result(db, code, "investment_advice") investment_advice_result = get_analysis_result(db_session, code, "investment_advice")
industry_competition_result = get_analysis_result(db, code, "industry_competition") industry_competition_result = get_analysis_result(db_session, code, "industry_competition")
financial_report_result = get_analysis_result(db, code, "financial_report") financial_report_result = get_analysis_result(db_session, code, "financial_report")
valuation_level_result = get_analysis_result(db, code, "valuation_level") valuation_level_result = get_analysis_result(db_session, code, "valuation_level")
# 从ai_response和extra_info中提取所需的值 # 从ai_response和extra_info中提取所需的值
investment_advice = investment_advice_result.ai_response if investment_advice_result else None investment_advice = investment_advice_result.ai_response if investment_advice_result else None
@ -1196,7 +1219,8 @@ def comprehensive_analysis():
"investment_advice": investment_advice, # 投资建议从ai_response获取 "investment_advice": investment_advice, # 投资建议从ai_response获取
"industry_space": industry_space, # 行业发展空间2:高速增长, 1:稳定经营, 0:不确定性大, -1:不利经营) "industry_space": industry_space, # 行业发展空间2:高速增长, 1:稳定经营, 0:不确定性大, -1:不利经营)
"financial_report_level": financial_report_level, # 经营质量2:优秀, 1:较好, 0:一般, -1:存在隐患,-2较大隐患 "financial_report_level": financial_report_level, # 经营质量2:优秀, 1:较好, 0:一般, -1:存在隐患,-2较大隐患
"pe_industry": pe_industry # 个股在行业的PE水平-1:高于行业, 0:接近行业, 1:低于行业) "pe_industry": pe_industry, # 个股在行业的PE水平-1:高于行业, 0:接近行业, 1:低于行业)
"tracks": track_results.get(code, []) # 添加赛道信息
}) })
logger.info(f"筛选出 {len(filtered_stocks)} 个符合条件的股票") logger.info(f"筛选出 {len(filtered_stocks)} 个符合条件的股票")
@ -1204,6 +1228,9 @@ def comprehensive_analysis():
except Exception as e: except Exception as e:
logger.error(f"应用企业画像筛选失败: {str(e)}") logger.error(f"应用企业画像筛选失败: {str(e)}")
filtered_stocks = [] filtered_stocks = []
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
# 统计各种状态的股票数量 # 统计各种状态的股票数量
success_count = sum(1 for item in investment_advice_list if item["status"] == "success") success_count = sum(1 for item in investment_advice_list if item["status"] == "success")
@ -1246,5 +1273,502 @@ def comprehensive_analysis():
"message": f"综合分析失败: {str(e)}" "message": f"综合分析失败: {str(e)}"
}), 500 }), 500
@app.route('/api/valuation_analysis', methods=['GET'])
def valuation_analysis():
"""
估值分析接口 - 获取股票的PE/PB估值分析数据
参数:
- stock_code: 股票代码和stock_name二选一
- stock_name: 股票名称和stock_code二选一
- start_date: 开始日期可选默认为2018-01-01
- industry_name: 行业名称可选
- concept_name: 概念板块名称可选
- metric: 估值指标可选值为'pe''pb'默认为'pe'
返回:
用于构建ECharts图表的数据对象
"""
try:
# 解析参数
stock_code = request.args.get('stock_code')
stock_name = request.args.get('stock_name')
start_date = request.args.get('start_date', '2018-01-01')
industry_name = request.args.get('industry_name')
concept_name = request.args.get('concept_name')
metric = request.args.get('metric', 'pe')
# 检查参数
if not stock_code and not stock_name:
return jsonify({
"status": "error",
"message": "请求格式错误: 需要提供stock_code或stock_name参数"
}), 400
if metric not in ['pe', 'pb']:
return jsonify({
"status": "error",
"message": "请求格式错误: metric参数必须为'pe''pb'"
}), 400
# 如果提供了stock_name但没有stock_code则查询stock_code
if not stock_code and stock_name:
# 这里简化处理,实际项目中应该查询数据库获取股票代码
return jsonify({
"status": "error",
"message": "暂不支持通过股票名称查询,请提供股票代码"
}), 400
# 验证日期格式
try:
datetime.strptime(start_date, '%Y-%m-%d')
except ValueError:
return jsonify({
"status": "error",
"message": f"日期格式错误: {start_date}应为YYYY-MM-DD格式"
}), 400
# 获取股票历史数据
stock_data = valuation_analyzer.get_historical_data(stock_code, start_date)
if stock_data.empty:
return jsonify({
"status": "error",
"message": f"未找到股票 {stock_code} 的历史数据"
}), 404
# 使用过滤后的数据
metric_filtered = f'{metric}_filtered' if f'{metric}_filtered' in stock_data.columns else metric
# 计算分位数
percentiles = valuation_analyzer.calculate_percentiles(stock_data, metric)
if not percentiles:
return jsonify({
"status": "error",
"message": f"无法计算股票 {stock_code}{metric} 分位数"
}), 500
# 获取行业平均数据
industry_data = None
if industry_name:
industry_data = valuation_analyzer.get_industry_avg_data(industry_name, start_date, metric)
# 获取概念板块平均数据
concept_data = None
if concept_name:
concept_data = valuation_analyzer.get_concept_avg_data(concept_name, start_date, metric)
# 获取股票名称
stock_name = valuation_analyzer.get_stock_name(stock_code) if not stock_name else stock_name
# 构建ECharts数据结构
dates = stock_data['timestamp'].dt.strftime('%Y-%m-%d').tolist()
# 准备行业和概念数据,使其与股票数据的日期对齐
aligned_industry_data = []
aligned_concept_data = []
# 日期映射,用于快速查找
if industry_data is not None and not industry_data.empty:
industry_date_map = dict(zip(industry_data['timestamp'].dt.strftime('%Y-%m-%d').tolist(),
industry_data[f'avg_{metric}'].tolist()))
# 根据股票日期创建对齐的行业数据
for date in dates:
aligned_industry_data.append(industry_date_map.get(date, None))
if concept_data is not None and not concept_data.empty:
concept_date_map = dict(zip(concept_data['timestamp'].dt.strftime('%Y-%m-%d').tolist(),
concept_data[f'avg_{metric}'].tolist()))
# 根据股票日期创建对齐的概念数据
for date in dates:
aligned_concept_data.append(concept_date_map.get(date, None))
# 构建结果
result = {
"status": "success",
"data": {
"title": {
"text": f"{stock_code} {stock_name} 历史{metric.upper()}分位数分析",
"subtext": f"当前{metric.upper()}百分位: {percentiles['percentile']:.2f}%"
},
"tooltip": {
"trigger": "axis",
"axisPointer": {
"type": "cross"
}
},
"legend": {
"data": [f"{stock_name} {metric.upper()}"]
},
"grid": {
"left": "3%",
"right": "4%",
"bottom": "3%",
"containLabel": True
},
"xAxis": {
"type": "category",
"boundaryGap": False,
"data": dates
},
"yAxis": {
"type": "value",
"name": f"{metric.upper()}"
},
"series": [
{
"name": f"{stock_name} {metric.upper()}",
"type": "line",
"data": stock_data[metric_filtered].tolist(),
"markLine": {
"data": [
{"name": "最小值", "yAxis": percentiles['min']},
{"name": "最大值", "yAxis": percentiles['max']},
{"name": "均值", "yAxis": percentiles['mean']},
{"name": "第一四分位数", "yAxis": percentiles['q1']},
{"name": "第三四分位数", "yAxis": percentiles['q3']},
{"name": "当前值", "yAxis": percentiles['current']}
]
}
}
],
"percentiles": {
"min": percentiles['min'],
"max": percentiles['max'],
"current": percentiles['current'],
"mean": percentiles['mean'],
"median": percentiles['median'],
"q1": percentiles['q1'],
"q3": percentiles['q3'],
"percentile": percentiles['percentile']
}
}
}
# 添加行业平均数据
if industry_data is not None and not industry_data.empty:
# 添加到legend
result["data"]["legend"]["data"].append(f"{industry_name}行业平均{metric.upper()}")
# 添加到series
result["data"]["series"].append({
"name": f"{industry_name}行业平均{metric.upper()}",
"type": "line",
"data": aligned_industry_data,
"lineStyle": {
"color": "#1e90ff", # 深蓝色
"width": 2
},
"itemStyle": {
"color": "#1e90ff",
"opacity": 0.9
},
"connectNulls": True # 连接空值点
})
# 添加行业统计信息
result["data"]["industry_stats"] = {
"name": industry_name,
"min_count": int(industry_data['stock_count'].min()),
"max_count": int(industry_data['stock_count'].max()),
"avg_count": float(industry_data['stock_count'].mean()),
"avg_value": float(industry_data[f'avg_{metric}'].mean())
}
# 添加概念板块平均数据
if concept_data is not None and not concept_data.empty:
# 添加到legend
result["data"]["legend"]["data"].append(f"{concept_name}概念平均{metric.upper()}")
# 添加到series
result["data"]["series"].append({
"name": f"{concept_name}概念平均{metric.upper()}",
"type": "line",
"data": aligned_concept_data,
"lineStyle": {
"color": "#ff7f50", # 珊瑚色
"width": 2
},
"itemStyle": {
"color": "#ff7f50",
"opacity": 0.9
},
"connectNulls": True # 连接空值点
})
# 添加概念统计信息
result["data"]["concept_stats"] = {
"name": concept_name,
"min_count": int(concept_data['stock_count'].min()),
"max_count": int(concept_data['stock_count'].max()),
"avg_count": float(concept_data['stock_count'].mean()),
"avg_value": float(concept_data[f'avg_{metric}'].mean())
}
return jsonify(result)
except Exception as e:
logger.error(f"估值分析失败: {str(e)}")
return jsonify({
"status": "error",
"message": f"估值分析失败: {str(e)}"
}), 500
@app.route('/api/industry/list', methods=['GET'])
def get_industry_list():
"""
获取行业列表
返回:
{
"status": "success",
"data": [
{"code": "100001", "name": "银行"},
{"code": "100002", "name": "保险"},
...
]
}
"""
try:
industries = industry_analyzer.get_industry_list()
if not industries:
return jsonify({
"status": "error",
"message": "未找到行业数据"
}), 404
return jsonify({
"status": "success",
"count": len(industries),
"data": industries
})
except Exception as e:
logger.error(f"获取行业列表失败: {str(e)}")
return jsonify({
"status": "error",
"message": f"获取行业列表失败: {str(e)}"
}), 500
@app.route('/api/industry/analysis', methods=['GET'])
def industry_analysis():
"""
行业分析接口 - 获取行业的PE/PB/PS估值分析数据和拥挤度指标
参数:
- industry_name: 行业名称
- metric: 估值指标可选值为'pe''pb''ps'默认为'pe'
- start_date: 开始日期可选默认为3年前
返回:
用于构建ECharts图表的行业估值数据对象包含估值指标和拥挤度
注意
- 行业PE/PB/PS计算中已剔除负值和极端值(如PE>1000)
- 所有百分位数据都是基于行业平均值计算的
- 拥挤度数据固定使用最近3年的数据不受start_date参数影响
"""
try:
# 解析参数
industry_name = request.args.get('industry_name')
metric = request.args.get('metric', 'pe')
start_date = request.args.get('start_date')
# 检查参数
if not industry_name:
return jsonify({
"status": "error",
"message": "请求格式错误: 需要提供industry_name参数"
}), 400
if metric not in ['pe', 'pb', 'ps']:
return jsonify({
"status": "error",
"message": "请求格式错误: metric参数必须为'pe''pb''ps'"
}), 400
# 获取行业分析数据
result = industry_analyzer.get_industry_analysis(industry_name, metric, start_date)
if not result.get('success', False):
return jsonify({
"status": "error",
"message": result.get('message', '未知错误')
}), 404
# 构建ECharts数据结构
metric_name = metric.upper()
# 估值指标数据
valuation_data = result['valuation']
percentiles = valuation_data['percentiles']
# 准备图例数据
legend_data = [
f"{industry_name}行业平均{metric_name}",
f"行业平均{metric_name}历史最小值",
f"行业平均{metric_name}历史最大值",
f"行业平均{metric_name}历史Q1",
f"行业平均{metric_name}历史Q3"
]
# 构建结果
response = {
"status": "success",
"data": {
"title": {
"text": f"{industry_name}行业历史{metric_name}分析",
"subtext": f"当前{metric_name}百分位: {percentiles['percentile']:.2f}%(剔除负值及极端值)"
},
"tooltip": {
"trigger": "axis",
"axisPointer": {
"type": "cross"
}
},
"legend": {
"data": legend_data
},
"grid": [
{
"left": "3%",
"right": "4%",
"top": "15%",
"height": "50%",
"containLabel": True
}
],
"xAxis": [
{
"type": "category",
"boundaryGap": False,
"data": valuation_data['dates'],
"axisLabel": {
"rotate": 45
},
"gridIndex": 0
}
],
"yAxis": [
{
"type": "value",
"name": f"{metric_name}",
"gridIndex": 0
}
],
"dataZoom": [
{
"type": "inside",
"start": 0,
"end": 100,
"xAxisIndex": [0]
},
{
"start": 0,
"end": 100,
"xAxisIndex": [0]
}
],
"series": [
{
"name": f"{industry_name}行业平均{metric_name}",
"type": "line",
"data": valuation_data['avg_values'],
"markLine": {
"data": [
{"name": "历史最小值", "yAxis": percentiles['min'], "lineStyle": {"color": "#28a745", "type": "dashed"}},
{"name": "历史最大值", "yAxis": percentiles['max'], "lineStyle": {"color": "#dc3545", "type": "dashed"}},
{"name": "历史均值", "yAxis": percentiles['mean'], "lineStyle": {"color": "#9400d3", "type": "dashed"}},
{"name": "历史Q1", "yAxis": percentiles['q1'], "lineStyle": {"color": "#28a745", "type": "dashed"}},
{"name": "历史Q3", "yAxis": percentiles['q3'], "lineStyle": {"color": "#dc3545", "type": "dashed"}}
]
}
},
{
"name": f"行业平均{metric_name}历史最小值",
"type": "line",
"data": valuation_data['min_values'],
"lineStyle": {"width": 1, "opacity": 0.4, "color": "#28a745"},
"areaStyle": {"opacity": 0.1, "color": "#28a745"}
},
{
"name": f"行业平均{metric_name}历史最大值",
"type": "line",
"data": valuation_data['max_values'],
"lineStyle": {"width": 1, "opacity": 0.4, "color": "#dc3545"},
"areaStyle": {"opacity": 0.1, "color": "#dc3545"}
},
{
"name": f"行业平均{metric_name}历史Q1",
"type": "line",
"data": valuation_data['q1_values'],
"lineStyle": {"width": 1, "opacity": 0.6, "color": "#28a745"}
},
{
"name": f"行业平均{metric_name}历史Q3",
"type": "line",
"data": valuation_data['q3_values'],
"lineStyle": {"width": 1, "opacity": 0.6, "color": "#dc3545"}
}
],
"percentiles": {
"min": percentiles['min'],
"max": percentiles['max'],
"current": percentiles['current'],
"mean": percentiles['mean'],
"median": percentiles['median'],
"q1": percentiles['q1'],
"q3": percentiles['q3'],
"percentile": percentiles['percentile'],
"stock_count": percentiles['stock_count'],
"date": percentiles.get('date', valuation_data['dates'][-1])
},
"toolbox": {
"feature": {
"saveAsImage": {}
}
},
"valuation": {
"dates": valuation_data['dates'],
"avg_values": valuation_data['avg_values']
}
}
}
# 添加拥挤度指标(如果有)- 作为独立数据不再添加到主图表series中
if "crowding" in result:
crowding_data = result["crowding"]
current_crowding = crowding_data["current"]
# 添加拥挤度数据作为单独部分
response["data"]["crowding"] = {
"current_ratio": current_crowding["ratio"],
"current_percentile": current_crowding["percentile"],
"level": current_crowding["level"],
"dates": crowding_data["dates"],
"percentiles": crowding_data["percentiles"],
"ratios": crowding_data["ratios"]
}
# 如果有行业成交比例的历史统计数据,也添加到响应中
if "ratio_stats" in current_crowding:
response["data"]["crowding"]["ratio_stats"] = current_crowding["ratio_stats"]
return jsonify(response)
except Exception as e:
logger.error(f"行业分析请求失败: {str(e)}")
return jsonify({
"status": "error",
"message": f"分析失败: {str(e)}"
}), 500
@app.route('/industry')
def industry_page():
"""渲染行业分析页面"""
return render_template('industry.html')
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)

View File

@ -121,7 +121,7 @@ class ChatBot:
"role": "user", "role": "user",
"content": user_input "content": user_input
}) })
extra_body = {"chat_template_kwargs": {"enable_thinking": True}}
# 调用OpenAI API流式输出 # 调用OpenAI API流式输出
stream = self.client.chat.completions.create( stream = self.client.chat.completions.create(
@ -132,6 +132,7 @@ class ChatBot:
max_tokens=max_tokens, max_tokens=max_tokens,
frequency_penalty=frequency_penalty, frequency_penalty=frequency_penalty,
stream=True, stream=True,
extra_body=extra_body,
timeout=600 timeout=600
) )

View File

@ -139,6 +139,12 @@ class EnterpriseScreener:
'operator': '>=', 'operator': '>=',
'value': -1 'value': -1
}, },
{
'dimension': 'industry_competition',
'field': 'industry_space',
'operator': '>=',
'value': 0
},
] ]
return self._screen_stocks_by_conditions(conditions, limit) return self._screen_stocks_by_conditions(conditions, limit)

View File

@ -85,7 +85,7 @@ redis_client = redis.Redis(
host='192.168.18.208', # Redis服务器地址根据实际情况调整 host='192.168.18.208', # Redis服务器地址根据实际情况调整
port=6379, port=6379,
password='wlkj2018', password='wlkj2018',
db=14, db=13,
socket_timeout=5, socket_timeout=5,
decode_responses=True decode_responses=True
) )
@ -102,7 +102,6 @@ class FundamentalAnalyzer:
# 千问打杂 # 千问打杂
# self.offline_bot_tl_qw = OfflineChatBot(platform="tl_qw_private", model_type="qwq") # self.offline_bot_tl_qw = OfflineChatBot(platform="tl_qw_private", model_type="qwq")
self.offline_bot_tl_qw = OfflineChatBot(platform="tl_qw_private", model_type="GLM") self.offline_bot_tl_qw = OfflineChatBot(platform="tl_qw_private", model_type="GLM")
self.db = next(get_db())
# 定义维度映射 # 定义维度映射
self.dimension_methods = { self.dimension_methods = {
@ -119,6 +118,7 @@ class FundamentalAnalyzer:
} }
def query_analysis(self, stock_code: str, stock_name: str, dimension: str) -> Tuple[bool, str, Optional[str], Optional[list]]: def query_analysis(self, stock_code: str, stock_name: str, dimension: str) -> Tuple[bool, str, Optional[str], Optional[list]]:
db_session1 = next(get_db())
"""查询分析结果,如果不存在则生成新的分析 """查询分析结果,如果不存在则生成新的分析
Args: Args:
@ -139,7 +139,7 @@ class FundamentalAnalyzer:
return False, f"无效的分析维度: {dimension}", None, None return False, f"无效的分析维度: {dimension}", None, None
# 查询数据库 # 查询数据库
result = get_analysis_result(self.db, stock_code, dimension) result = get_analysis_result(db_session1, stock_code, dimension)
if result: if result:
# 如果存在结果,直接返回 # 如果存在结果,直接返回
@ -149,10 +149,10 @@ class FundamentalAnalyzer:
# 如果不存在,生成新的分析 # 如果不存在,生成新的分析
logger.info(f"数据库中未找到 {stock_name}({stock_code}) 的 {dimension} 分析结果,开始生成") logger.info(f"数据库中未找到 {stock_name}({stock_code}) 的 {dimension} 分析结果,开始生成")
success = self.dimension_methods[dimension](stock_code, stock_name) success = self.dimension_methods[dimension](stock_code, stock_name)
db_session2 = next(get_db())
if success: if success:
# 重新查询数据库获取结果 # 重新查询数据库获取结果
result = get_analysis_result(self.db, stock_code, dimension) result = get_analysis_result(db_session2, stock_code, dimension)
if result: if result:
return True, result.ai_response, result.reasoning_process, result.references return True, result.ai_response, result.reasoning_process, result.references
@ -161,6 +161,11 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"查询分析结果失败: {str(e)}") logger.error(f"查询分析结果失败: {str(e)}")
return False, f"查询失败: {str(e)}", None, None return False, f"查询失败: {str(e)}", None, None
finally:
if 'db_session1' in locals() and db_session1 is not None: # 确保 db_session 已定义
db_session1.close() # <--- 关闭会话
if 'db_session2' in locals() and db_session2 is not None: # 确保 db_session 已定义
db_session2.close() # <--- 关闭会话
def _remove_references_from_response(self, response: str) -> str: def _remove_references_from_response(self, response: str) -> str:
"""从响应中移除参考资料部分 """从响应中移除参考资料部分
@ -181,6 +186,7 @@ class FundamentalAnalyzer:
def analyze_company_profile(self, stock_code: str, stock_name: str) -> bool: def analyze_company_profile(self, stock_code: str, stock_name: str) -> bool:
"""分析公司简介""" """分析公司简介"""
try: try:
db_session = next(get_db())
# 构建提示词 # 构建提示词
prompt = f"""请对{stock_name}({stock_code})进行公司简介分析严格要求输出控制在500字以内主营业务介绍300字成立背景与历程200字请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})进行公司简介分析严格要求输出控制在500字以内主营业务介绍300字成立背景与历程200字请严格按照以下格式输出
@ -201,7 +207,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
return save_analysis_result( return save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="company_profile", dimension="company_profile",
@ -213,10 +219,14 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析公司简介失败: {str(e)}") logger.error(f"分析公司简介失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def analyze_management_ownership(self, stock_code: str, stock_name: str) -> bool: def analyze_management_ownership(self, stock_code: str, stock_name: str) -> bool:
"""分析实控人和管理层持股情况""" """分析实控人和管理层持股情况"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})的实控人和管理层持股情况进行简要分析要求输出控制在300字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})的实控人和管理层持股情况进行简要分析要求输出控制在300字以内请严格按照以下格式输出
1. 实控人情况 1. 实控人情况
@ -234,7 +244,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="management_ownership", dimension="management_ownership",
@ -253,6 +263,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析实控人和管理层持股情况失败: {str(e)}") logger.error(f"分析实控人和管理层持股情况失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_management_info(self, ownership_text: str, stock_code: str, stock_name: str) -> Dict[str, int]: def extract_management_info(self, ownership_text: str, stock_code: str, stock_name: str) -> Dict[str, int]:
"""从实控人和管理层持股分析中提取持股情况和能力评价并更新数据库 """从实控人和管理层持股分析中提取持股情况和能力评价并更新数据库
@ -266,6 +279,7 @@ class FundamentalAnalyzer:
Dict[str, int]: 包含shareholding和ability的字典 Dict[str, int]: 包含shareholding和ability的字典
""" """
try: try:
db_session = next(get_db())
# 提取持股情况 # 提取持股情况
shareholding = self._extract_shareholding_status(ownership_text, stock_code, stock_name) shareholding = self._extract_shareholding_status(ownership_text, stock_code, stock_name)
@ -273,10 +287,10 @@ class FundamentalAnalyzer:
ability = self._extract_management_ability(ownership_text, stock_code, stock_name) ability = self._extract_management_ability(ownership_text, stock_code, stock_name)
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "management_ownership") result = get_analysis_result(db_session, stock_code, "management_ownership")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="management_ownership", dimension="management_ownership",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -295,6 +309,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取实控人和管理层信息失败: {str(e)}") logger.error(f"提取实控人和管理层信息失败: {str(e)}")
return {"shareholding": 0, "ability": 0} return {"shareholding": 0, "ability": 0}
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def _extract_shareholding_status(self, ownership_text: str, stock_code: str, stock_name: str) -> int: def _extract_shareholding_status(self, ownership_text: str, stock_code: str, stock_name: str) -> int:
"""从实控人和管理层持股分析中提取持股减持情况 """从实控人和管理层持股分析中提取持股减持情况
@ -399,6 +416,7 @@ class FundamentalAnalyzer:
def analyze_financial_report(self, stock_code: str, stock_name: str) -> bool: def analyze_financial_report(self, stock_code: str, stock_name: str) -> bool:
"""分析企业财报情况""" """分析企业财报情况"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})的财报情况进行简要分析严格要求最新财报情况200字以内最新业绩预告情况100字以内近三年变化趋势150字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})的财报情况进行简要分析严格要求最新财报情况200字以内最新业绩预告情况100字以内近三年变化趋势150字以内请严格按照以下格式输出
1. 最新财报情况 1. 最新财报情况
@ -425,7 +443,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="financial_report", dimension="financial_report",
@ -444,6 +462,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析财报情况失败: {str(e)}") logger.error(f"分析财报情况失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_financial_report_level(self, report_text: str, stock_code: str, stock_name: str) -> int: def extract_financial_report_level(self, report_text: str, stock_code: str, stock_name: str) -> int:
"""从财报分析中提取财报水平评级并更新数据库 """从财报分析中提取财报水平评级并更新数据库
@ -457,6 +478,7 @@ class FundamentalAnalyzer:
int: 财报水平评级 (2:边际向好无风险, 1:稳定风险小, 0:稳定有隐患, -1:波动大有隐患, -2:波动大隐患大) int: 财报水平评级 (2:边际向好无风险, 1:稳定风险小, 0:稳定有隐患, -1:波动大有隐患, -2:波动大隐患大)
""" """
try: try:
db_session = next(get_db())
# 使用在线模型分析财报水平 # 使用在线模型分析财报水平
prompt = f"""请对{stock_name}({stock_code})的财报水平进行专业分析,并返回对应的数值评级: prompt = f"""请对{stock_name}({stock_code})的财报水平进行专业分析,并返回对应的数值评级:
@ -472,8 +494,9 @@ class FundamentalAnalyzer:
请仅返回一个数值210-1-2不要包含任何解释或说明""" 请仅返回一个数值210-1-2不要包含任何解释或说明"""
# 使用在线模型进行分析 # 使用在线模型进行分析
response = self.chat_bot.chat(prompt) response = self.offline_bot_tl_qw.chat(prompt,temperature=0)
full_response = response["response"].strip() full_response = response
# full_response = response["response"].strip()
# 提取数值 # 提取数值
report_level = 0 # 默认值 report_level = 0 # 默认值
@ -492,10 +515,10 @@ class FundamentalAnalyzer:
logger.warning(f"无法将提取的财报水平评级值转换为整数设置为默认值0") logger.warning(f"无法将提取的财报水平评级值转换为整数设置为默认值0")
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "financial_report") result = get_analysis_result(db_session, stock_code, "financial_report")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="financial_report", dimension="financial_report",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -513,10 +536,14 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取财报水平评级失败: {str(e)}") logger.error(f"提取财报水平评级失败: {str(e)}")
return 0 return 0
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def analyze_industry_competition(self, stock_code: str, stock_name: str) -> bool: def analyze_industry_competition(self, stock_code: str, stock_name: str) -> bool:
"""分析行业发展趋势和竞争格局""" """分析行业发展趋势和竞争格局"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})所在行业的发展趋势和竞争格局进行简要分析要求输出控制在400字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})所在行业的发展趋势和竞争格局进行简要分析要求输出控制在400字以内请严格按照以下格式输出
1. 市场需求 1. 市场需求
@ -544,7 +571,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="industry_competition", dimension="industry_competition",
@ -563,6 +590,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析行业发展趋势和竞争格局失败: {str(e)}") logger.error(f"分析行业发展趋势和竞争格局失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_industry_space(self, industry_text: str, stock_code: str, stock_name: str) -> int: def extract_industry_space(self, industry_text: str, stock_code: str, stock_name: str) -> int:
"""从行业发展趋势和竞争格局中提取行业发展空间并更新数据库 """从行业发展趋势和竞争格局中提取行业发展空间并更新数据库
@ -576,6 +606,7 @@ class FundamentalAnalyzer:
int: 行业发展空间值 (2:高速增长, 1:稳定经营, 0:不确定性大, -1:不利经营) int: 行业发展空间值 (2:高速增长, 1:稳定经营, 0:不确定性大, -1:不利经营)
""" """
try: try:
db_session = next(get_db())
# 使用离线模型分析行业发展空间 # 使用离线模型分析行业发展空间
prompt = f"""请分析以下{stock_name}({stock_code})的行业发展趋势和竞争格局文本,评估当前市场环境、阶段和竞争格局对企业未来的影响,并返回对应的数值: prompt = f"""请分析以下{stock_name}({stock_code})的行业发展趋势和竞争格局文本,评估当前市场环境、阶段和竞争格局对企业未来的影响,并返回对应的数值:
- 如果当前市场环境阶段和竞争格局符合未来企业高速增长返回数值"2" - 如果当前市场环境阶段和竞争格局符合未来企业高速增长返回数值"2"
@ -605,10 +636,10 @@ class FundamentalAnalyzer:
logger.warning(f"无法将提取的行业发展空间值转换为整数: {space_value_str}设置为默认值0") logger.warning(f"无法将提取的行业发展空间值转换为整数: {space_value_str}设置为默认值0")
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "industry_competition") result = get_analysis_result(db_session, stock_code, "industry_competition")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="industry_competition", dimension="industry_competition",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -626,10 +657,14 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取行业发展空间失败: {str(e)}") logger.error(f"提取行业发展空间失败: {str(e)}")
return 0 return 0
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def analyze_recent_projects(self, stock_code: str, stock_name: str) -> bool: def analyze_recent_projects(self, stock_code: str, stock_name: str) -> bool:
"""分析近期重大订单和项目进展""" """分析近期重大订单和项目进展"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})的近期重大订单和项目进展进行简要分析要求输出控制在500字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})的近期重大订单和项目进展进行简要分析要求输出控制在500字以内请严格按照以下格式输出
1. 主要业务领域进展300字左右 1. 主要业务领域进展300字左右
@ -649,7 +684,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="recent_projects", dimension="recent_projects",
@ -668,6 +703,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析近期重大订单和项目进展失败: {str(e)}") logger.error(f"分析近期重大订单和项目进展失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_major_events(self, projects_text: str, stock_code: str, stock_name: str) -> int: def extract_major_events(self, projects_text: str, stock_code: str, stock_name: str) -> int:
"""从重大订单和项目进展中提取进展情况并更新数据库 """从重大订单和项目进展中提取进展情况并更新数据库
@ -681,6 +719,7 @@ class FundamentalAnalyzer:
int: 项目进展评价 (1:超预期, 0:顺利但未超预期, -1:不顺利或在验证中) int: 项目进展评价 (1:超预期, 0:顺利但未超预期, -1:不顺利或在验证中)
""" """
try: try:
db_session = next(get_db())
# 使用离线模型分析项目进展情况 # 使用离线模型分析项目进展情况
prompt = f"""请分析以下{stock_name}({stock_code})的重大订单和项目进展情况,并返回对应的数值: prompt = f"""请分析以下{stock_name}({stock_code})的重大订单和项目进展情况,并返回对应的数值:
- 如果项目进展顺利且订单交付/建厂等超预期返回数值"1" - 如果项目进展顺利且订单交付/建厂等超预期返回数值"1"
@ -710,10 +749,10 @@ class FundamentalAnalyzer:
logger.warning(f"无法将提取的项目进展评价值转换为整数: {events_value_str}设置为默认值0") logger.warning(f"无法将提取的项目进展评价值转换为整数: {events_value_str}设置为默认值0")
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "recent_projects") result = get_analysis_result(db_session, stock_code, "recent_projects")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="recent_projects", dimension="recent_projects",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -731,10 +770,14 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取重大项目进展评价失败: {str(e)}") logger.error(f"提取重大项目进展评价失败: {str(e)}")
return 0 return 0
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def analyze_stock_discussion(self, stock_code: str, stock_name: str) -> bool: def analyze_stock_discussion(self, stock_code: str, stock_name: str) -> bool:
"""分析股吧讨论内容""" """分析股吧讨论内容"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})的股吧讨论内容进行简要分析要求输出控制在400字以内(主要讨论话题200字重要信息汇总200字),请严格按照以下格式输出: prompt = f"""请对{stock_name}({stock_code})的股吧讨论内容进行简要分析要求输出控制在400字以内(主要讨论话题200字重要信息汇总200字),请严格按照以下格式输出:
1. 主要讨论话题 1. 主要讨论话题
@ -753,7 +796,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="stock_discussion", dimension="stock_discussion",
@ -772,6 +815,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析股吧讨论内容失败: {str(e)}") logger.error(f"分析股吧讨论内容失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_stock_discussion_emotion(self, discussion_text: str, stock_code: str, stock_name: str) -> int: def extract_stock_discussion_emotion(self, discussion_text: str, stock_code: str, stock_name: str) -> int:
"""从股吧讨论内容中提取市场情绪并更新数据库 """从股吧讨论内容中提取市场情绪并更新数据库
@ -785,6 +831,7 @@ class FundamentalAnalyzer:
int: 市场情绪值 (1:乐观, 0:中性, -1:悲观) int: 市场情绪值 (1:乐观, 0:中性, -1:悲观)
""" """
try: try:
db_session = next(get_db())
# 使用离线模型分析市场情绪 # 使用离线模型分析市场情绪
prompt = f"""请分析以下{stock_name}({stock_code})的股吧讨论内容分析,判断整体市场情绪倾向,并返回对应的数值: prompt = f"""请分析以下{stock_name}({stock_code})的股吧讨论内容分析,判断整体市场情绪倾向,并返回对应的数值:
- 如果股吧讨论情绪偏乐观返回数值"1" - 如果股吧讨论情绪偏乐观返回数值"1"
@ -815,10 +862,10 @@ class FundamentalAnalyzer:
logger.warning(f"无法将提取的股吧情绪值转换为整数: {emotion_value_str}设置为默认值0") logger.warning(f"无法将提取的股吧情绪值转换为整数: {emotion_value_str}设置为默认值0")
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "stock_discussion") result = get_analysis_result(db_session, stock_code, "stock_discussion")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="stock_discussion", dimension="stock_discussion",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -836,10 +883,14 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取股吧情绪值失败: {str(e)}") logger.error(f"提取股吧情绪值失败: {str(e)}")
return 0 return 0
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def analyze_industry_cooperation(self, stock_code: str, stock_name: str) -> bool: def analyze_industry_cooperation(self, stock_code: str, stock_name: str) -> bool:
"""分析产业链上下游合作动态""" """分析产业链上下游合作动态"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})最近半年内的产业链上下游合作动态进行简要分析要求输出控制在400字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})最近半年内的产业链上下游合作动态进行简要分析要求输出控制在400字以内请严格按照以下格式输出
1. 重要客户合作200字左右 1. 重要客户合作200字左右
@ -858,7 +909,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="industry_cooperation", dimension="industry_cooperation",
@ -877,6 +928,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析产业链上下游合作动态失败: {str(e)}") logger.error(f"分析产业链上下游合作动态失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_collaboration_dynamics(self, cooperation_text: str, stock_code: str, stock_name: str) -> int: def extract_collaboration_dynamics(self, cooperation_text: str, stock_code: str, stock_name: str) -> int:
"""从产业链上下游合作动态中提取合作动态质量评级并更新数据库 """从产业链上下游合作动态中提取合作动态质量评级并更新数据库
@ -890,6 +944,7 @@ class FundamentalAnalyzer:
int: 合作动态质量值 (2:质量高, 1:一般, 0:/质量低, -1:负面) int: 合作动态质量值 (2:质量高, 1:一般, 0:/质量低, -1:负面)
""" """
try: try:
db_session = next(get_db())
# 使用在线模型分析合作动态质量 # 使用在线模型分析合作动态质量
prompt = f"""请评估{stock_name}({stock_code})的产业链上下游合作动态质量,并返回相应数值: prompt = f"""请评估{stock_name}({stock_code})的产业链上下游合作动态质量,并返回相应数值:
@ -924,10 +979,10 @@ class FundamentalAnalyzer:
logger.warning(f"无法将提取的合作动态质量值转换为整数设置为默认值0") logger.warning(f"无法将提取的合作动态质量值转换为整数设置为默认值0")
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "industry_cooperation") result = get_analysis_result(db_session, stock_code, "industry_cooperation")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="industry_cooperation", dimension="industry_cooperation",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -945,10 +1000,14 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取产业链合作动态质量失败: {str(e)}") logger.error(f"提取产业链合作动态质量失败: {str(e)}")
return 0 return 0
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def analyze_target_price(self, stock_code: str, stock_name: str) -> bool: def analyze_target_price(self, stock_code: str, stock_name: str) -> bool:
"""分析券商和研究机构目标股价""" """分析券商和研究机构目标股价"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})的券商和研究机构目标股价进行简要分析要求输出控制在300字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})的券商和研究机构目标股价进行简要分析要求输出控制在300字以内请严格按照以下格式输出
1. 目标股价情况150字左右 1. 目标股价情况150字左右
@ -966,7 +1025,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="target_price", dimension="target_price",
@ -985,6 +1044,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析目标股价失败: {str(e)}") logger.error(f"分析目标股价失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_target_price_info(self, price_text: str, stock_code: str, stock_name: str) -> Dict[str, int]: def extract_target_price_info(self, price_text: str, stock_code: str, stock_name: str) -> Dict[str, int]:
"""从目标股价分析中提取券商评级和上涨/下跌空间并更新数据库 """从目标股价分析中提取券商评级和上涨/下跌空间并更新数据库
@ -998,15 +1060,16 @@ class FundamentalAnalyzer:
Dict[str, int]: 包含securities_rating和odds的字典 Dict[str, int]: 包含securities_rating和odds的字典
""" """
try: try:
db_session = next(get_db())
# 提取券商评级和上涨/下跌空间 # 提取券商评级和上涨/下跌空间
securities_rating = self._extract_securities_rating(price_text) securities_rating = self._extract_securities_rating(price_text)
odds = self._extract_odds(price_text) odds = self._extract_odds(price_text)
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "target_price") result = get_analysis_result(db_session, stock_code, "target_price")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="target_price", dimension="target_price",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -1025,6 +1088,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取目标股价信息失败: {str(e)}") logger.error(f"提取目标股价信息失败: {str(e)}")
return {"securities_rating": 0, "odds": 0} return {"securities_rating": 0, "odds": 0}
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def _extract_securities_rating(self, price_text: str) -> int: def _extract_securities_rating(self, price_text: str) -> int:
"""从目标股价分析中提取券商评级 """从目标股价分析中提取券商评级
@ -1116,6 +1182,7 @@ class FundamentalAnalyzer:
def analyze_valuation_level(self, stock_code: str, stock_name: str) -> bool: def analyze_valuation_level(self, stock_code: str, stock_name: str) -> bool:
"""分析企业PE和PB在历史分位水平和行业平均水平的对比情况""" """分析企业PE和PB在历史分位水平和行业平均水平的对比情况"""
try: try:
db_session = next(get_db())
prompt = f"""请对{stock_name}({stock_code})的估值水平进行简要分析要求输出控制在300字以内请严格按照以下格式输出 prompt = f"""请对{stock_name}({stock_code})的估值水平进行简要分析要求输出控制在300字以内请严格按照以下格式输出
1. 历史估值水平150字左右 1. 历史估值水平150字左右
@ -1134,7 +1201,7 @@ class FundamentalAnalyzer:
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="valuation_level", dimension="valuation_level",
@ -1153,6 +1220,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"分析估值水平失败: {str(e)}") logger.error(f"分析估值水平失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_valuation_classification(self, valuation_text: str, stock_code: str, stock_name: str) -> Dict[str, int]: def extract_valuation_classification(self, valuation_text: str, stock_code: str, stock_name: str) -> Dict[str, int]:
"""从估值水平分析中提取历史和行业估值分类并更新数据库 """从估值水平分析中提取历史和行业估值分类并更新数据库
@ -1170,6 +1240,7 @@ class FundamentalAnalyzer:
- pb_industry: PB行业对比分类 (-1:高于行业, 0:接近行业, 1:低于行业) - pb_industry: PB行业对比分类 (-1:高于行业, 0:接近行业, 1:低于行业)
""" """
try: try:
db_session = next(get_db())
# 直接提取四个分类值 # 直接提取四个分类值
pe_historical = self._extract_pe_historical(valuation_text) pe_historical = self._extract_pe_historical(valuation_text)
pb_historical = self._extract_pb_historical(valuation_text) pb_historical = self._extract_pb_historical(valuation_text)
@ -1177,10 +1248,10 @@ class FundamentalAnalyzer:
pb_industry = self._extract_pb_industry(valuation_text) pb_industry = self._extract_pb_industry(valuation_text)
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "valuation_level") result = get_analysis_result(db_session, stock_code, "valuation_level")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="valuation_level", dimension="valuation_level",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -1211,6 +1282,9 @@ class FundamentalAnalyzer:
"pe_industry": 0, "pe_industry": 0,
"pb_industry": 0 "pb_industry": 0
} }
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def _extract_pe_historical(self, valuation_text: str) -> int: def _extract_pe_historical(self, valuation_text: str) -> int:
"""从估值水平分析中提取PE历史分位分类 """从估值水平分析中提取PE历史分位分类
@ -1375,17 +1449,18 @@ class FundamentalAnalyzer:
def generate_investment_advice(self, stock_code: str, stock_name: str) -> bool: def generate_investment_advice(self, stock_code: str, stock_name: str) -> bool:
"""生成最终投资建议""" """生成最终投资建议"""
try: try:
db_session = next(get_db())
# 收集所有维度的分析结果排除investment_advice # 收集所有维度的分析结果排除investment_advice
all_results = {} all_results = {}
analysis_dimensions = [dim for dim in self.dimension_methods.keys() if dim != "investment_advice"] analysis_dimensions = [dim for dim in self.dimension_methods.keys() if dim != "investment_advice"]
for dimension in analysis_dimensions: for dimension in analysis_dimensions:
# 查询数据库 # 查询数据库
result = get_analysis_result(self.db, stock_code, dimension) result = get_analysis_result(db_session, stock_code, dimension)
if not result: if not result:
# 如果数据库中没有结果,生成新的分析 # 如果数据库中没有结果,生成新的分析
self.dimension_methods[dimension](stock_code, stock_name) self.dimension_methods[dimension](stock_code, stock_name)
result = get_analysis_result(self.db, stock_code, dimension) result = get_analysis_result(db_session, stock_code, dimension)
if result: if result:
all_results[dimension] = result.ai_response all_results[dimension] = result.ai_response
@ -1408,12 +1483,12 @@ class FundamentalAnalyzer:
{json.dumps(all_results, ensure_ascii=False, indent=2)}""" {json.dumps(all_results, ensure_ascii=False, indent=2)}"""
self.offline_bot.clear_history() self.offline_bot.clear_history()
# 使用离线模型生成建议 # 使用离线模型生成建议
result = self.offline_bot.chat(prompt,max_tokens=20000) result = self.offline_bot.chat(prompt,max_tokens=16384)
# 清理模型输出 # 清理模型输出
result = TextProcessor.clean_thought_process(result) result = TextProcessor.clean_thought_process(result)
# 保存到数据库 # 保存到数据库
success = save_analysis_result( success = save_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
dimension="investment_advice", dimension="investment_advice",
@ -1433,6 +1508,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"生成投资建议失败: {str(e)}") logger.error(f"生成投资建议失败: {str(e)}")
return False return False
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def extract_investment_advice_type(self, advice_text: str, stock_code: str, stock_name: str) -> str: def extract_investment_advice_type(self, advice_text: str, stock_code: str, stock_name: str) -> str:
"""从投资建议中提取建议类型并更新数据库 """从投资建议中提取建议类型并更新数据库
@ -1446,6 +1524,7 @@ class FundamentalAnalyzer:
str: 提取的投资建议类型短期中期长期不建议 None str: 提取的投资建议类型短期中期长期不建议 None
""" """
try: try:
db_session = next(get_db())
valid_types = ["短期", "中期", "长期", "不建议"] valid_types = ["短期", "中期", "长期", "不建议"]
max_attempts = 3 max_attempts = 3
@ -1453,10 +1532,10 @@ class FundamentalAnalyzer:
found_type = self._try_extract_advice_type(advice_text, max_attempts) found_type = self._try_extract_advice_type(advice_text, max_attempts)
# 更新数据库中的记录 # 更新数据库中的记录
result = get_analysis_result(self.db, stock_code, "investment_advice") result = get_analysis_result(db_session, stock_code, "investment_advice")
if result: if result:
update_analysis_result( update_analysis_result(
self.db, db_session,
stock_code=stock_code, stock_code=stock_code,
dimension="investment_advice", dimension="investment_advice",
ai_response=result.ai_response, ai_response=result.ai_response,
@ -1475,6 +1554,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"提取投资建议类型失败: {str(e)}") logger.error(f"提取投资建议类型失败: {str(e)}")
return None return None
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def _try_extract_advice_type(self, advice_text: str, max_attempts: int = 3) -> Optional[str]: def _try_extract_advice_type(self, advice_text: str, max_attempts: int = 3) -> Optional[str]:
"""尝试多次从投资建议中提取建议类型 """尝试多次从投资建议中提取建议类型
@ -1592,6 +1674,7 @@ class FundamentalAnalyzer:
Optional[str]: 生成的PDF文件路径如果失败则返回None Optional[str]: 生成的PDF文件路径如果失败则返回None
""" """
try: try:
db_session = next(get_db())
# 检查是否已存在PDF文件 # 检查是否已存在PDF文件
reports_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'reports') reports_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'reports')
os.makedirs(reports_dir, exist_ok=True) os.makedirs(reports_dir, exist_ok=True)
@ -1627,7 +1710,7 @@ class FundamentalAnalyzer:
# 收集所有可用的分析结果 # 收集所有可用的分析结果
content_dict = {} content_dict = {}
for dimension in self.dimension_methods.keys(): for dimension in self.dimension_methods.keys():
result = get_analysis_result(self.db, stock_code, dimension) result = get_analysis_result(db_session, stock_code, dimension)
if result and result.ai_response: if result and result.ai_response:
content_dict[dimension_names[dimension]] = result.ai_response content_dict[dimension_names[dimension]] = result.ai_response
@ -1707,6 +1790,9 @@ class FundamentalAnalyzer:
except Exception as e: except Exception as e:
logger.error(f"生成PDF报告失败: {str(e)}") logger.error(f"生成PDF报告失败: {str(e)}")
return None return None
finally:
if 'db_session' in locals() and db_session is not None: # 确保 db_session 已定义
db_session.close() # <--- 关闭会话
def is_stock_locked(self, stock_code: str, dimension: str) -> bool: def is_stock_locked(self, stock_code: str, dimension: str) -> bool:
"""检查股票是否已被锁定 """检查股票是否已被锁定

View File

@ -29,6 +29,7 @@ engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
class GpFundamentalAnalysis(Base): class GpFundamentalAnalysis(Base):
"""基本面分析结果表""" """基本面分析结果表"""
__tablename__ = "fundamental_analysis" __tablename__ = "fundamental_analysis"
@ -46,10 +47,12 @@ class GpFundamentalAnalysis(Base):
def __repr__(self): def __repr__(self):
return f"<GpFundamentalAnalysis(stock_code={self.stock_code}, dimension={self.dimension})>" return f"<GpFundamentalAnalysis(stock_code={self.stock_code}, dimension={self.dimension})>"
def init_db(): def init_db():
"""初始化数据库""" """初始化数据库"""
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
def get_db(): def get_db():
"""获取数据库会话""" """获取数据库会话"""
db = SessionLocal() db = SessionLocal()
@ -58,6 +61,7 @@ def get_db():
finally: finally:
db.close() db.close()
# 定义基本面分析的维度 # 定义基本面分析的维度
ANALYSIS_DIMENSIONS = { ANALYSIS_DIMENSIONS = {
"company_profile": "公司简介", "company_profile": "公司简介",
@ -70,6 +74,7 @@ ANALYSIS_DIMENSIONS = {
"investment_suggestion": "投资建议" "investment_suggestion": "投资建议"
} }
def save_analysis_result( def save_analysis_result(
db: Session, db: Session,
stock_code: str, stock_code: str,
@ -133,6 +138,7 @@ def save_analysis_result(
db.rollback() db.rollback()
return False return False
def get_analysis_result(db: Session, stock_code: str, dimension: str): def get_analysis_result(db: Session, stock_code: str, dimension: str):
"""获取特定股票特定维度的分析结果""" """获取特定股票特定维度的分析结果"""
try: try:
@ -145,6 +151,7 @@ def get_analysis_result(db: Session, stock_code: str, dimension: str):
logger.error(f"获取分析结果失败: {str(e)}") logger.error(f"获取分析结果失败: {str(e)}")
return None return None
def update_analysis_result( def update_analysis_result(
db: Session, db: Session,
stock_code: str, stock_code: str,

View File

@ -11,7 +11,7 @@ XUEQIU_HEADERS = {
'Accept-Encoding': 'gzip, deflate, br, zstd', 'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Client-Version': 'v2.44.75', 'Client-Version': 'v2.44.75',
'Cookie': 'bid=d298883c846926e46f61128493d71969_ltocon4a; device_id=99947aa416bde5e7418bd51327850c56; HMACCOUNT=2D95237D28EF1309; s=be11f4j7l4; cookiesu=651724318004914; Hm_lvt_1db88642e346389874251b5a1eded6e3=1739155871; xq_a_token=2b33ad6ac971d226d2d8e9e2487621e834ad3550; xqat=2b33ad6ac971d226d2d8e9e2487621e834ad3550; xq_r_token=f17217fe00776426e761885c67f799dac6d960f4; xq_id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1aWQiOjg0OTM0MTE2MzQsImlzcyI6InVjIiwiZXhwIjoxNzQzNjQ1MjY2LCJjdG0iOjE3NDEwNTMyNjY3MjYsImNpZCI6ImQ5ZDBuNEFadXAifQ.UCe60fXpKIKGEHjA8fwHQ4m0WBCdpCllnuQrLQ_EbpSf9joPboNq4xanvs15SFl8EzEvWRFqgyaDb-TLUkb-YuSXb2vnDVQKeHW1CmQMwihpAS8bz9FKnspw3PeQZLJTfgwUFl7uTKRkjNu3x1lk1OJ3SmyjlGDAEEQgsvZlK-OZLcXvoTdOlM3B9ppERN0vicURiZkxVT68B_j4wEmegOWdsaEhTEXeBfhzMu1ObmztdcTolhBPB59vDFpMmVJz7YjWkkrESgq0Kjx6xpx_afwtMY2NDciuslSA3ji5zDG2objiX8tGzLFFqHdslZRg0ulK-MJeUvH6hpCbSQ7cEw; xq_is_login=1; u=8493411634; is_overseas=0; Hm_lpvt_1db88642e346389874251b5a1eded6e3=1741144661; ssxmod_itna=YqIxnD0DyGehDkDhx+xmqAKQAQDOD2GSAD0x0dGM9/DILq7=GFKDCEKLumb2G+R0DeYrAG2mq1DBLAoDSxD67DK4GTI+OGe41i7G2vppqW7h+tK3FKoZUPo20do4gdOeIbed5=xB3DExGkeQ0re4iiWx0rD0eDPxDYDG4aD7PDoxDrF8YDjl7pOUgwoUxDKx0kDY5Dw62wDYPDWxDFNIGFNUAeDD5Dn1SUw4DWDWpmYDDzYjQ4wcY4DmRw7SHfkj8XeGENN4G1jD0H7Dt4NUkBwwGv7f6NivW+vz3DvxDkU8oGLbwmGfEhiueq0DoOGI7dqBe77oeAeFYxeD4KiGe04YDqK7iYODWGD7GeqOxk2QwlGIi7IDqSOdkowe3znMzN3sMuev7DdCDpEGox4mB=5rxq7Y70vmrGvE5VYYiAAMYpF04bWA7GiYiDD; ssxmod_itna2=YqIxnD0DyGehDkDhx+xmqAKQAQDOD2GSAD0x0dGM9/DILq7=GFKDCEKLumb2G+R0DeYrAG2mqTDGRW0wqQD7P4mGKeDsYGtidoeFEjpbF4WjtDqn+ao7pCSAt8ko=xEKjfG+96eKGNSNbhha7W5a2xKenxPKzaRC2BqW4hv+rf5eyoKauABacPT1TOpp+FIm2A7ssEHQA1Gai3fwGtMHG/fHC4vHiRvHctgGuDSCN7aAA=wWzgf4i=Fu3YduYqK8S1Cm=lFc20GFzq2FrS=xN5=RZWpyKlj6hKaSkvIWvFXNjoghz=iFf1j4mIygYfeADbPxzQq7QTOPyEPacND=IRWU2i4m=GgQN4Dozhjux=X1Irf6xAvHO2xAA1iRqp6I1DKxR77GSDhygvO7QwcQVEPwO25Wpj6RgD2574e7+IRnv8+xAqzBqGYeQDh1jn64aeYD', 'Cookie': 'cookiesu=811743062689927; device_id=33fa3c7fca4a65f8f4354e10ed6b7470; HMACCOUNT=8B64A2E3C307C8C0; s=c611ttmqlj; xq_is_login=1; u=8493411634; bid=4065a77ca57a69c83405d6e591ab5449_m8r2nhs8; xq_a_token=90d76a1c24a9d8fd1b868cd7b94fabcdd6cb2f0a; xqat=90d76a1c24a9d8fd1b868cd7b94fabcdd6cb2f0a; xq_id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1aWQiOjg0OTM0MTE2MzQsImlzcyI6InVjIiwiZXhwIjoxNzQ4NTI1NTA4LCJjdG0iOjE3NDU5MzM1MDg4NDcsImNpZCI6ImQ5ZDBuNEFadXAifQ.Xj00ujbYNYb3jt0wev1VZSj37wy3oRdTXohaOXp0xGoV6xOS055QcxaeXzbE6yaKQDgwUC4NVCEQLfJ49LvxWDSvWGEI7y2j-_ZzH-ZoHc6-RZ7pQdLLlTeRSM17Sg1JZZWG4xwk4yb_aHoWyUznjODTOgyg8EOnhDPO6-bI8SrXXXV8a-TE0ZpDw1EIimKYzhCQR0qwEnm2swEoN3YRfyiBvuMg5Cr2zqgnrKQAafquUZmwFvudIVlYG1HppoMnrbzXhQ4II0tP8duvcT-mzabQE_OaY0RM5u9mwthMfm5KPThEVb_o74s_SweMv6vHZDRMaxxzrnlM4MgW-4mmpg; xq_r_token=6a95ad5270dea5256d4b5d14683bf40cdabce730; Hm_lvt_1db88642e346389874251b5a1eded6e3=1746410725; is_overseas=0; Hm_lpvt_1db88642e346389874251b5a1eded6e3=1746517072; ssxmod_itna=eqGxBDnGKYuxcD4kDRgxYq7ueYKS8DBP01Dp2xQyP08D60DB40Q0QHhqDyGmZATmkDq3e4pEBDDl=BrYDSxD6FDK4GTh86DOICelaOowCWKGLkUpeBUlCR5QW/+Dp0KY0zzSQW0P40aDmKDUoqqhW7YDeWaDCeDQxirDD4DAWPDFxibDiWIo4Ddb+ovz3owpDGrDlKDRx07pYoDbxDaDGpmYCDxbS7eDD5DnESXI4DWDWapeDDzelQx+xoxDm+mk4YLpB80RjgDqYoD9h4DsZigl/LgATiAkS=BWvewff3DvxDk2IEGU4TpKavbec4xqiibt7Y34qe2qF+QKGxAGUmrKiiYiiqP+xmhx84qmx4RxCIT5MqbF7YQFYRxY=7K5iK4rZ0y/mWV/HYerYTBqiAbYEk4hNDRu44bQmtBhN4+QlbqY3PA0PogkWgieD; ssxmod_itna2=eqGxBDnGKYuxcD4kDRgxYq7ueYKS8DBP01Dp2xQyP08D60DB40Q0QHhqDyGmZATmkDq3e4pEYDA4gYHYeqRD7Pzg+ReDsXYe6pj6SmpZ2qUqQe6DhjRtXa2S6bph7ZGARuppraeTqypju30Gj37fAmhhj8qrSzx6KdQfXAG4Zj3f5WLPMjTIV77RYy+TnziIlSLPEBg3M3ZuL41LKWTf6lS330QyxSLXCOYnxlCGLl46fKbFElPrcG4=C=IQgQ9tGaCLfmgxZQBQtoiIQprYcbYfuRcCYM1y5OH37aMWU4=yQYv/LnWnGq5OSclDIyYpvCnDYqv9aUBn4=mQR0pGcsjuHQvLm9F7iPmPHYH+CcLjIjGBntKepw870/+FKq52z9YXHYaq4fbH0v2GHseRe=WHIgD9HY=FQrnctq/GFA3EhBKctmx4wvim9+bWX4UI+2FP+b8F9P0lS7rWz3PU9m4NmqwK0Wux6+xjn4qPtcYUD8OKpAYFK42qAid5Dt9RqiiqEiaeQhEo+aQwP2BYIpfihOiY3bre4t9rNnxro0q8GI==I2hDD',
'Referer': 'https://weibo.com/u/7735765253', 'Referer': 'https://weibo.com/u/7735765253',
'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"', 'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Mobile': '?0',
@ -79,9 +79,8 @@ MODEL_CONFIGS = {
"base_url": "http://192.168.16.174:1234/v1/", "base_url": "http://192.168.16.174:1234/v1/",
"api_key": "none", "api_key": "none",
"models": { "models": {
"glm-z1": "glm-z1-rumination-32b-0414",
"glm-4": "glm-4-32b-0414-abliterated", "glm-4": "glm-4-32b-0414-abliterated",
"ds_v1": "mlx-community/DeepSeek-R1-4bit", "qwen3": "qwen3-235b-a22b",
} }
}, },
# 天链-千问 # 天链-千问

View File

@ -1,4 +1,5 @@
# coding:utf-8 # coding:utf-8
# 使用说明:先从通达信里面,选择板块,然后右上角的选项->数据导出然后把数据复制到C:/Users/Administrator/Desktop/temp/概念板块.csv里面。导入即可导入之后处理一下将000233这种变成SZ000233excel公式是=IF(OR(LEFT(D2,2)="60",LEFT(D2,2)="68"),"SH",IF(OR(LEFT(D2,2)="30",LEFT(D2,1)="0"),"SZ",IF(LEFT(D2,1)="8","BJ","")))
import pandas as pd import pandas as pd
from sqlalchemy import create_engine from sqlalchemy import create_engine
@ -40,6 +41,9 @@ def import_concept_sectors(file_path, db_url, table_name):
if __name__ == "__main__": if __name__ == "__main__":
# 示例调用 # 示例调用
file_path = "C:/Users/xy/Desktop/temp/概念板块.csv"
db_url = 'mysql+pymysql://root:Chlry#$.8@192.168.18.199:3306/db_gp_cj' db_url = 'mysql+pymysql://root:Chlry#$.8@192.168.18.199:3306/db_gp_cj'
import_concept_sectors(file_path, db_url, "gp_gnbk") file_path = "C:/Users/Administrator/Desktop/temp/行业板块.csv"
import_concept_sectors(file_path, db_url, "gp_hybk")
# file_path = "C:/Users/Administrator/Desktop/temp/概念板块.csv"
# import_concept_sectors(file_path, db_url, "gp_gnbk")

View File

@ -1,56 +0,0 @@
/*
Navicat Premium Data Transfer
Source Server :
Source Server Type : MySQL
Source Server Version : 80031
Source Host : 192.168.1.82:3306
Source Schema : db_gp_cj
Target Server Type : MySQL
Target Server Version : 80031
File Encoding : 65001
Date: 05/03/2025 13:40:26
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for gp_code_all
-- ----------------------------
DROP TABLE IF EXISTS `gp_code_all`;
CREATE TABLE `gp_code_all` (
`id` int NOT NULL AUTO_INCREMENT,
`gp_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
`gp_code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
`gp_code_two` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
`gp_code_three` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
`market_cap` decimal(20, 2) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6475 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for gp_day_data
-- ----------------------------
DROP TABLE IF EXISTS `gp_day_data`;
CREATE TABLE `gp_day_data` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`symbol` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '个股代码',
`timestamp` timestamp(0) NULL DEFAULT NULL COMMENT '时间戳',
`volume` bigint NULL DEFAULT NULL COMMENT '数量',
`open` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '开始价',
`high` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '最高价',
`low` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '最低价',
`close` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '结束价',
`chg` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '变化数值',
`percent` decimal(10, 2) NULL DEFAULT NULL COMMENT '变化百分比',
`turnoverrate` decimal(10, 2) NULL DEFAULT NULL COMMENT '换手率',
`amount` bigint NULL DEFAULT NULL COMMENT '成交金额',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_symbol`(`symbol`) USING BTREE,
INDEX `idx_timestamp`(`timestamp`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 58520387 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = DYNAMIC;
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -1,3 +1,4 @@
# coding:utf-8 # coding:utf-8
import requests import requests
@ -6,140 +7,104 @@ from sqlalchemy import create_engine, text
from datetime import datetime from datetime import datetime
from tqdm import tqdm from tqdm import tqdm
from config import XUEQIU_HEADERS from config import XUEQIU_HEADERS
import gc
import time
class StockDailyDataCollector: class StockDailyDataCollector:
"""股票日线数据采集器类""" """股票日线数据采集器类"""
def __init__(self, db_url): def __init__(self, db_url):
""" self.engine = create_engine(
初始化采集器 db_url,
pool_size=5,
Parameters: max_overflow=10,
----------- pool_recycle=3600
db_url : str )
数据库连接URL
"""
self.engine = create_engine(db_url)
self.headers = XUEQIU_HEADERS self.headers = XUEQIU_HEADERS
def fetch_all_stock_codes(self): def fetch_all_stock_codes(self):
"""从数据库获取所有股票代码""" query = "SELECT gp_code FROM gp_code_all"
query = "SELECT gp_code FROM gp_code_all_copy"
df = pd.read_sql(query, self.engine) df = pd.read_sql(query, self.engine)
return df['gp_code'].tolist() return df['gp_code'].tolist()
def fetch_daily_stock_data(self, symbol, begin): def fetch_daily_stock_data(self, symbol, begin):
""" url = f"https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol={symbol}&begin={begin}&period=day&type=before&count=-1&indicator=kline,pe,pb,ps,pcf,market_capital,agt,ggt,balance"
获取股票日线数据 try:
response = requests.get(url, headers=self.headers, timeout=10)
Parameters:
-----------
symbol : str
股票代码
begin : int
开始时间戳毫秒
"""
url = f"https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol={symbol}&begin={begin}&period=day&type=before&count=-1500&indicator=kline,pe,pb,ps,pcf,market_capital,agt,ggt,balance"
response = requests.get(url, headers=self.headers)
return response.json() return response.json()
except Exception as e:
print(f"Request error for {symbol}: {e}")
return {'error_code': -1, 'error_description': str(e)}
def save_daily_data_to_database(self, data, symbol): def transform_data(self, data, symbol):
"""
保存日线数据到数据库
Parameters:
-----------
data : dict
API返回的数据
symbol : str
股票代码
"""
try: try:
items = data['data']['item'] items = data['data']['item']
columns = data['data']['column'] columns = data['data']['column']
except KeyError as e: except KeyError as e:
print(f"KeyError for {symbol}: {e}") print(f"KeyError for {symbol}: {e}")
return return None
df = pd.DataFrame(items, columns=columns) df = pd.DataFrame(items, columns=columns)
df['symbol'] = symbol df['symbol'] = symbol
# 数据库中有的字段
required_columns = ['timestamp', 'volume', 'open', 'high', 'low', 'close', required_columns = ['timestamp', 'volume', 'open', 'high', 'low', 'close',
'chg', 'percent', 'turnoverrate', 'amount', 'symbol'] 'chg', 'percent', 'turnoverrate', 'amount', 'symbol', 'pb', 'pe', 'ps']
# 检查并保留实际存在的字段
existing_columns = [col for col in required_columns if col in df.columns] existing_columns = [col for col in required_columns if col in df.columns]
df = df[existing_columns] df = df[existing_columns]
# 数据类型转换
if 'timestamp' in df.columns: if 'timestamp' in df.columns:
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai') df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai')
df.to_sql('gp_day_data', self.engine, if_exists='append', index=False) return df
def save_batch_to_database(self, batch):
if batch:
df_all = pd.concat(batch, ignore_index=True)
df_all.to_sql('gp_day_data', self.engine, if_exists='append', index=False)
def fetch_data_for_date(self, date=None): def fetch_data_for_date(self, date=None):
"""
获取指定日期或当天的日线数据并保存到数据库
Parameters:
-----------
date : str, optional
日期字符串格式为'YYYY-MM-DD'如果为None则获取当天数据
"""
if date is None: if date is None:
# 如果没有指定日期,使用当天日期
start_date = datetime.now() start_date = datetime.now()
date_str = start_date.strftime('%Y-%m-%d') date_str = start_date.strftime('%Y-%m-%d')
else: else:
start_date = datetime.strptime(date, '%Y-%m-%d') start_date = datetime.strptime(date, '%Y-%m-%d')
date_str = date date_str = date
# 在插入数据之前执行删除操作 delete_query = text("DELETE FROM gp_day_data WHERE `timestamp` LIKE :date_str")
delete_query = text(f"DELETE FROM gp_day_data WHERE `timestamp` LIKE :date_str") with self.engine.begin() as conn:
with self.engine.connect() as conn:
conn.execute(delete_query, {"date_str": f"{date_str}%"}) conn.execute(delete_query, {"date_str": f"{date_str}%"})
# 获取所有股票代码
stock_codes = self.fetch_all_stock_codes() stock_codes = self.fetch_all_stock_codes()
# 循环请求每只股票的数据并保存,使用进度条显示进度
for symbol in tqdm(stock_codes, desc=f"Fetching and saving daily stock data for {date_str}"):
begin = int(start_date.replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000) begin = int(start_date.replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000)
batch_data = []
for idx, symbol in enumerate(tqdm(stock_codes, desc=f"Fetching and saving daily stock data for {date_str}")):
data = self.fetch_daily_stock_data(symbol, begin) data = self.fetch_daily_stock_data(symbol, begin)
if data['error_code'] == 0:
self.save_daily_data_to_database(data, symbol)
else:
print(f"Error fetching data for {symbol} on {date_str}: {data['error_description']}")
if data.get('error_code') == 0:
df = self.transform_data(data, symbol)
if df is not None:
batch_data.append(df)
else:
print(f"Error fetching data for {symbol} on {date_str}: {data.get('error_description')}")
if len(batch_data) >= 100:
self.save_batch_to_database(batch_data)
batch_data.clear()
gc.collect()
# Save remaining data
if batch_data:
self.save_batch_to_database(batch_data)
gc.collect()
self.engine.dispose()
print(f"Daily data fetching and saving completed for {date_str}.") print(f"Daily data fetching and saving completed for {date_str}.")
def collect_stock_daily_data(db_url, date=None): def collect_stock_daily_data(db_url, date=None):
"""
快捷方法收集股票日线数据
Parameters:
-----------
db_url : str
数据库连接URL
date : str, optional
日期字符串格式为'YYYY-MM-DD'如果为None则获取当天数据
"""
collector = StockDailyDataCollector(db_url) collector = StockDailyDataCollector(db_url)
collector.fetch_data_for_date(date) collector.fetch_data_for_date(date)
if __name__ == "__main__": if __name__ == "__main__":
# 示例调用
db_url = 'mysql+pymysql://root:Chlry#$.8@192.168.18.199:3306/db_gp_cj' db_url = 'mysql+pymysql://root:Chlry#$.8@192.168.18.199:3306/db_gp_cj'
# 方法1使用快捷函数获取当天数据
collect_stock_daily_data(db_url) collect_stock_daily_data(db_url)
# 方法2使用快捷函数获取指定日期数据
# collect_stock_daily_data(db_url, '2020-01-01')
# 方法3使用完整的类
# collector = StockDailyDataCollector(db_url)
# collector.fetch_data_for_date() # 获取当天数据
# collector.fetch_data_for_date('2024-09-11') # 获取指定日期数据

View File

@ -24,13 +24,13 @@ class StockMinuteDataCollector:
def fetch_all_stock_codes(self): def fetch_all_stock_codes(self):
"""从数据库获取所有股票代码""" """从数据库获取所有股票代码"""
query = "SELECT gp_code FROM gp_code_all_copy" query = "SELECT gp_code FROM gp_code_all"
df = pd.read_sql(query, self.engine) df = pd.read_sql(query, self.engine)
return df['gp_code'].tolist() return df['gp_code'].tolist()
def update_market_cap(self, symbol, market_cap): def update_market_cap(self, symbol, market_cap):
"""更新数据库中的市值信息""" """更新数据库中的市值信息"""
query = text("UPDATE gp_code_all_copy SET market_cap = :market_cap WHERE gp_code = :symbol") query = text("UPDATE gp_code_all SET market_cap = :market_cap WHERE gp_code = :symbol")
with self.engine.connect() as conn: with self.engine.connect() as conn:
conn.execute(query, {'market_cap': market_cap, 'symbol': symbol}) conn.execute(query, {'market_cap': market_cap, 'symbol': symbol})

8
src/simulation.log Normal file
View File

@ -0,0 +1,8 @@
2025-04-30 16:11:18,786 - stock_simulation - ERROR - 获取所有账户出错: 'Engine' object has no attribute 'cursor'
2025-04-30 16:13:40,220 - stock_simulation - ERROR - 获取所有账户出错: 'Connection' object has no attribute 'cursor'
2025-04-30 16:16:26,095 - stock_simulation - ERROR - 获取所有账户出错: 'Engine' object has no attribute 'cursor'
2025-04-30 16:20:02,876 - stock_simulation - ERROR - 获取所有账户出错: 'Engine' object has no attribute 'cursor'
2025-04-30 16:20:22,822 - stock_simulation - ERROR - 获取所有账户出错: 'Engine' object has no attribute 'cursor'
2025-04-30 16:21:14,438 - stock_simulation - ERROR - 获取所有账户出错: 'Engine' object has no attribute 'cursor'
2025-04-30 16:23:38,622 - stock_simulation - ERROR - 获取所有账户出错: 'Connection' object has no attribute 'cursor'
2025-04-30 16:26:40,281 - stock_simulation - ERROR - 获取所有账户出错: 'Connection' object has no attribute 'cursor'

6
src/static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

120
src/static/css/style.css Normal file
View File

@ -0,0 +1,120 @@
/* 股票估值分析工具样式 */
body {
background-color: #f8f9fa;
font-family: 'Microsoft YaHei', sans-serif;
}
.card {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
border: none;
}
.card-header {
border-radius: 0.5rem 0.5rem 0 0 !important;
}
.btn-primary {
background-color: #2c68cf;
border-color: #2c68cf;
}
.btn-primary:hover {
background-color: #1d56b9;
border-color: #1d56b9;
}
#valuationChart {
border-radius: 0.25rem;
background-color: #fff;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.1);
width: 100% !important;
height: 500px !important;
min-height: 350px !important;
}
/* 数据表格样式 */
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0, 123, 255, 0.05);
}
.table td, .table th {
vertical-align: middle;
}
/* 表格中的百分比值根据大小变色 */
.percent-value {
font-weight: bold;
}
.percent-low {
color: #28a745; /* 绿色,低估值 */
}
.percent-medium {
color: #ffc107; /* 黄色,中等估值 */
}
.percent-high {
color: #dc3545; /* 红色,高估值 */
}
/* 当前值特殊高亮 */
.current-value {
font-weight: bold;
font-size: 1.1em;
color: #007bff; /* 蓝色 */
}
/* 高亮表格行 */
.highlight-row {
background-color: #f8f9fa !important;
border-left: 3px solid #007bff;
}
.highlight-row td {
padding-left: 10px;
}
/* 加载动画 */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
/* 响应式调整 */
@media (max-width: 768px) {
#valuationChart {
height: 350px !important;
}
/* 修改标题在移动设备上的显示 */
h1 {
font-size: 1.5rem;
}
/* 确保按钮在移动设备上的显示 */
.btn {
margin-bottom: 5px;
}
}
/* 保证图表显示完整 */
.card-body {
overflow: hidden;
}
/* 确保结果卡片内容完全显示 */
#resultCard {
display: none;
}
#resultCard.d-none {
display: none !important;
}
#resultCard:not(.d-none) {
display: block !important;
}

7
src/static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

45
src/static/js/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
src/static/js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

478
src/static/js/valuation.js Normal file
View File

@ -0,0 +1,478 @@
/**
* 股票估值分析工具前端JS
*/
document.addEventListener('DOMContentLoaded', function() {
// 获取DOM元素
const valuationForm = document.getElementById('valuationForm');
const stockCodeInput = document.getElementById('stockCode');
const startDateInput = document.getElementById('startDate');
const metricSelect = document.getElementById('metric');
const industryNameInput = document.getElementById('industryName');
const conceptNameInput = document.getElementById('conceptName');
const analyzeBtn = document.getElementById('analyzeBtn');
const resetBtn = document.getElementById('resetBtn');
const loadingSpinner = document.getElementById('loadingSpinner');
const resultCard = document.getElementById('resultCard');
const resultTitle = document.getElementById('resultTitle');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const percentileTable = document.getElementById('percentileTable');
const industryStats = document.getElementById('industryStats');
const industryStatsTable = document.getElementById('industryStatsTable');
const conceptStats = document.getElementById('conceptStats');
const conceptStatsTable = document.getElementById('conceptStatsTable');
// 定义图表实例
let myChart = null;
// 监听表单提交事件
valuationForm.addEventListener('submit', function(event) {
event.preventDefault();
analyzeValuation();
});
// 监听重置按钮点击事件
resetBtn.addEventListener('click', function() {
resetForm();
});
/**
* 分析股票估值
*/
function analyzeValuation() {
// 显示加载中状态
showLoading(true);
// 隐藏之前的结果和错误信息
resultCard.classList.add('d-none');
errorAlert.classList.add('d-none');
// 获取表单数据
const stockCode = stockCodeInput.value.trim();
const startDate = startDateInput.value;
const metric = metricSelect.value;
const industryName = industryNameInput.value.trim();
const conceptName = conceptNameInput.value.trim();
// 构建请求URL
let url = `/api/valuation_analysis?stock_code=${stockCode}&start_date=${startDate}&metric=${metric}`;
if (industryName) {
url += `&industry_name=${encodeURIComponent(industryName)}`;
}
if (conceptName) {
url += `&concept_name=${encodeURIComponent(conceptName)}`;
}
// 发送API请求
fetch(url)
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || '请求失败');
});
}
return response.json();
})
.then(data => {
if (data.status === 'success') {
// 渲染分析结果
renderValuationResults(data);
} else {
showError(data.message || '分析失败,请稍后再试');
}
})
.catch(error => {
showError(error.message || '请求失败,请检查网络连接');
})
.finally(() => {
showLoading(false);
});
}
/**
* 渲染估值分析结果
*/
function renderValuationResults(data) {
// 显示结果卡片(在渲染图表前确保容器可见)
resultCard.classList.remove('d-none');
// 更新标题
resultTitle.textContent = data.data.title.text;
// 渲染分位数据表格
renderPercentileTable(data.data.percentiles, data.data.yAxis.name);
// 渲染行业统计(如果有)
if (data.data.industry_stats) {
renderIndustryStats(data.data.industry_stats, data.data.yAxis.name);
industryStats.classList.remove('d-none');
} else {
industryStats.classList.add('d-none');
}
// 渲染概念板块统计(如果有)
if (data.data.concept_stats) {
renderConceptStats(data.data.concept_stats, data.data.yAxis.name);
conceptStats.classList.remove('d-none');
} else {
conceptStats.classList.add('d-none');
}
// 渲染图表等待DOM更新后
setTimeout(() => {
renderChart(data.data);
// 滚动到结果区域
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
/**
* 渲染ECharts图表
*/
function renderChart(chartData) {
// 初始化图表
const chartContainer = document.getElementById('valuationChart');
// 确保容器可见
resultCard.classList.remove('d-none');
// 如果已经存在图表实例,则销毁
if (myChart) {
myChart.dispose();
}
// 创建新的图表实例
myChart = echarts.init(chartContainer);
// 调整股票线的系列配置
if (chartData.series && chartData.series.length > 0) {
// 获取第一个系列(股票主线)
const mainSeries = chartData.series[0];
// 移除当前值的markLine只保留其他几条线
if (mainSeries.markLine && mainSeries.markLine.data) {
mainSeries.markLine.data = mainSeries.markLine.data.filter(item =>
item.name !== "当前值"
);
// 调整分位线颜色
mainSeries.markLine.data.forEach(item => {
if (item.name === "最小值" || item.name === "第一四分位数") {
item.lineStyle = { color: '#28a745', type: 'dashed' }; // 绿色
} else if (item.name === "最大值" || item.name === "第三四分位数") {
item.lineStyle = { color: '#dc3545', type: 'dashed' }; // 红色
} else if (item.name === "均值") {
item.lineStyle = { color: '#9400d3', type: 'dashed' }; // 紫色
}
});
}
}
// 调整行业和概念线的颜色
if (chartData.series && chartData.series.length > 1) {
// 行业线(通常是第二个系列)
if (chartData.series[1]) {
chartData.series[1].lineStyle = {
color: '#1e90ff', // 深蓝色
width: 2
};
// 增加透明度以区分
chartData.series[1].itemStyle = {
color: '#1e90ff',
opacity: 0.9
};
}
// 概念线(通常是第三个系列)
if (chartData.series[2]) {
chartData.series[2].lineStyle = {
color: '#ff7f50', // 珊瑚色
width: 2
};
// 增加透明度以区分
chartData.series[2].itemStyle = {
color: '#ff7f50',
opacity: 0.9
};
}
}
// 设置图表选项
const option = {
title: {
text: chartData.title.text,
subtext: chartData.title.subtext,
left: 'center',
top: 5
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
legend: {
data: chartData.legend.data,
top: 60
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: 100,
containLabel: true
},
toolbox: {
feature: {
saveAsImage: {}
}
},
xAxis: {
type: 'category',
boundaryGap: false,
data: chartData.xAxis.data,
axisLabel: {
rotate: 45,
interval: Math.floor(chartData.xAxis.data.length / 10)
}
},
yAxis: {
type: 'value',
name: chartData.yAxis.name,
axisLabel: {
formatter: '{value}'
}
},
series: chartData.series,
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
start: 0,
end: 100
}
]
};
// 为所有markLine添加标签
option.series.forEach(series => {
if (series.markLine && series.markLine.data) {
series.markLine.label = {
formatter: '{b}: {c}',
position: 'middle'
};
}
});
// 设置图表选项
myChart.setOption(option);
// 手动触发一次resize确保图表正确渲染
setTimeout(() => {
myChart.resize();
}, 50);
// 响应式调整
window.addEventListener('resize', function() {
if (myChart) {
myChart.resize();
}
});
}
/**
* 渲染分位数表格
*/
function renderPercentileTable(percentiles, metricName) {
// 清空表格
percentileTable.innerHTML = '';
// 格式化函数
const formatValue = value => value ? value.toFixed(2) : 'N/A';
// 获取百分位类别
const getPercentileClass = value => {
if (value <= 33) return 'percent-low';
if (value <= 66) return 'percent-medium';
return 'percent-high';
};
// 创建表格行
const rows = [
{
label: '当前值',
value: formatValue(percentiles.current),
class: 'current-value' // 添加特殊样式类
},
{
label: '百分位',
value: `${formatValue(percentiles.percentile)}%`,
class: getPercentileClass(percentiles.percentile)
},
{
label: '最小值',
value: formatValue(percentiles.min),
class: 'percent-low' // 绿色
},
{
label: '最大值',
value: formatValue(percentiles.max),
class: 'percent-high' // 红色
},
{
label: '均值',
value: formatValue(percentiles.mean)
},
{
label: '中位数',
value: formatValue(percentiles.median)
},
{
label: '第一四分位数',
value: formatValue(percentiles.q1),
class: 'percent-low' // 绿色
},
{
label: '第三四分位数',
value: formatValue(percentiles.q3),
class: 'percent-high' // 红色
}
];
// 添加行到表格
rows.forEach(row => {
const tr = document.createElement('tr');
// 添加特殊类以突出当前值行
if (row.label === '当前值' || row.label === '百分位') {
tr.classList.add('highlight-row');
}
const tdLabel = document.createElement('td');
tdLabel.textContent = row.label;
tr.appendChild(tdLabel);
const tdValue = document.createElement('td');
tdValue.textContent = row.value;
if (row.class) {
tdValue.classList.add('percent-value', row.class);
}
tr.appendChild(tdValue);
percentileTable.appendChild(tr);
});
}
/**
* 渲染行业统计数据
*/
function renderIndustryStats(stats, metricName) {
// 清空表格
industryStatsTable.innerHTML = '';
// 创建表格行
const rows = [
{ label: '行业名称', value: stats.name },
{ label: '成份股数量', value: `${stats.min_count} - ${stats.max_count}` },
{ label: '平均成份股', value: stats.avg_count.toFixed(1) + '只' },
{ label: `平均${metricName}`, value: stats.avg_value.toFixed(2) }
];
// 添加行到表格
rows.forEach(row => {
const tr = document.createElement('tr');
const tdLabel = document.createElement('td');
tdLabel.textContent = row.label;
tr.appendChild(tdLabel);
const tdValue = document.createElement('td');
tdValue.textContent = row.value;
tr.appendChild(tdValue);
industryStatsTable.appendChild(tr);
});
}
/**
* 渲染概念板块统计数据
*/
function renderConceptStats(stats, metricName) {
// 清空表格
conceptStatsTable.innerHTML = '';
// 创建表格行
const rows = [
{ label: '概念名称', value: stats.name },
{ label: '成份股数量', value: `${stats.min_count} - ${stats.max_count}` },
{ label: '平均成份股', value: stats.avg_count.toFixed(1) + '只' },
{ label: `平均${metricName}`, value: stats.avg_value.toFixed(2) }
];
// 添加行到表格
rows.forEach(row => {
const tr = document.createElement('tr');
const tdLabel = document.createElement('td');
tdLabel.textContent = row.label;
tr.appendChild(tdLabel);
const tdValue = document.createElement('td');
tdValue.textContent = row.value;
tr.appendChild(tdValue);
conceptStatsTable.appendChild(tr);
});
}
/**
* 显示错误信息
*/
function showError(message) {
errorMessage.textContent = message;
errorAlert.classList.remove('d-none');
// 滚动到错误信息
errorAlert.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* 重置表单
*/
function resetForm() {
valuationForm.reset();
// 隐藏结果和错误信息
resultCard.classList.add('d-none');
errorAlert.classList.add('d-none');
// 销毁图表
if (myChart) {
myChart.dispose();
myChart = null;
}
}
/**
* 显示/隐藏加载状态
*/
function showLoading(isLoading) {
if (isLoading) {
loadingSpinner.classList.remove('d-none');
analyzeBtn.disabled = true;
} else {
loadingSpinner.classList.add('d-none');
analyzeBtn.disabled = false;
}
}
});

152
src/templates/index.html Normal file
View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>股票估值分析工具</title>
<!-- 引入jQuery必须在Bootstrap之前-->
<script src="../static/js/jquery.min.js"></script>
<!-- 引入Bootstrap CSS -->
<link href="../static/css/bootstrap.min.css" rel="stylesheet">
<!-- 引入ECharts -->
<script src="../static/js/echarts.min.js"></script>
<!-- 引入自定义CSS -->
<link href="/static/css/style.css" rel="stylesheet">
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">股票估值分析工具</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="/">个股估值分析</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/industry">行业估值分析</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
<h1 class="text-center mb-4">个股估值分析</h1>
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">股票估值分析</h5>
</div>
<div class="card-body">
<form id="valuationForm" class="row g-3">
<div class="col-md-6">
<label for="stockCode" class="form-label">股票代码</label>
<input type="text" class="form-control" id="stockCode" placeholder="例如: SH603986" required>
<div class="form-text">请输入股票代码带前缀SH/SZ</div>
</div>
<div class="col-md-6">
<label for="startDate" class="form-label">开始日期</label>
<input type="date" class="form-control" id="startDate" value="2018-01-01" required>
</div>
<div class="col-md-4">
<label for="metric" class="form-label">估值指标</label>
<select class="form-select" id="metric" required>
<option value="pe" selected>PE (市盈率)</option>
<option value="pb">PB (市净率)</option>
</select>
</div>
<div class="col-md-4">
<label for="industryName" class="form-label">行业名称(可选)</label>
<input type="text" class="form-control" id="industryName" placeholder="例如: 半导体">
</div>
<div class="col-md-4">
<label for="conceptName" class="form-label">概念板块(可选)</label>
<input type="text" class="form-control" id="conceptName" placeholder="例如: 人工智能">
</div>
<div class="col-12 text-center mt-4">
<button type="submit" class="btn btn-primary px-4" id="analyzeBtn">
<span class="spinner-border spinner-border-sm d-none" id="loadingSpinner" role="status" aria-hidden="true"></span>
分析
</button>
<button type="button" class="btn btn-secondary px-4" id="resetBtn">重置</button>
</div>
</form>
</div>
</div>
<div class="card mb-4 d-none" id="resultCard">
<div class="card-header bg-success text-white">
<h5 class="mb-0" id="resultTitle">分析结果</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<!-- 图表容器,明确指定宽度和高度 -->
<div id="valuationChart" style="width:100%; height:500px; min-height:350px;"></div>
</div>
<div class="col-md-4">
<!-- 估值分位数据 -->
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">估值分位数据</h6>
</div>
<div class="card-body">
<table class="table table-striped">
<tbody id="percentileTable">
<!-- 将通过JS动态填充 -->
</tbody>
</table>
</div>
</div>
<!-- 行业/概念统计 -->
<div class="card mt-3 d-none" id="industryStats">
<div class="card-header bg-info text-white">
<h6 class="mb-0">行业统计数据</h6>
</div>
<div class="card-body">
<table class="table table-striped">
<tbody id="industryStatsTable">
<!-- 将通过JS动态填充 -->
</tbody>
</table>
</div>
</div>
<div class="card mt-3 d-none" id="conceptStats">
<div class="card-header bg-info text-white">
<h6 class="mb-0">概念板块统计数据</h6>
</div>
<div class="card-body">
<table class="table table-striped">
<tbody id="conceptStatsTable">
<!-- 将通过JS动态填充 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="alert alert-danger d-none" id="errorAlert">
<strong>错误:</strong> <span id="errorMessage"></span>
</div>
</div>
<!-- 引入Bootstrap JS (确保在jQuery之后) -->
<script src="../static/js/bootstrap.bundle.min.js"></script>
<!-- 引入自定义JS -->
<script src="/static/js/valuation.js"></script>
</body>
</html>

169
src/templates/industry.html Normal file
View File

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>行业估值分析工具</title>
<!-- 引入jQuery必须在Bootstrap之前-->
<script src="../static/js/jquery.min.js"></script>
<!-- 引入Bootstrap CSS -->
<link href="../static/css/bootstrap.min.css" rel="stylesheet">
<!-- 引入ECharts -->
<script src="../static/js/echarts.min.js"></script>
<!-- 引入自定义CSS -->
<link href="/static/css/style.css" rel="stylesheet">
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">股票估值分析工具</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">个股估值分析</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/industry">行业估值分析</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
<h1 class="text-center mb-4">行业估值分析</h1>
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">行业估值与拥挤度分析</h5>
</div>
<div class="card-body">
<form id="industryForm" class="row g-3">
<div class="col-md-6">
<label for="industryName" class="form-label">行业名称</label>
<select class="form-select" id="industryName" required>
<option value="" selected disabled>请选择行业</option>
<!-- 将通过API动态填充 -->
</select>
</div>
<div class="col-md-3">
<label for="metric" class="form-label">估值指标</label>
<select class="form-select" id="metric" required>
<option value="pe" selected>PE (市盈率)</option>
<option value="pb">PB (市净率)</option>
<option value="ps">PS (市销率)</option>
</select>
</div>
<div class="col-md-3">
<label for="startDate" class="form-label">开始日期</label>
<input type="date" class="form-control" id="startDate">
<div class="form-text">不填默认为3年前</div>
</div>
<div class="col-12 mt-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="showCrowding" checked>
<label class="form-check-label" for="showCrowding">
显示行业拥挤度数据固定为近3年
</label>
</div>
</div>
<div class="col-12 text-center mt-4">
<button type="submit" class="btn btn-primary px-4" id="analyzeBtn">
<span class="spinner-border spinner-border-sm d-none" id="loadingSpinner" role="status" aria-hidden="true"></span>
分析
</button>
<button type="button" class="btn btn-secondary px-4" id="resetBtn">重置</button>
</div>
</form>
</div>
</div>
<div class="card mb-4 d-none" id="resultCard">
<div class="card-header bg-success text-white">
<h5 class="mb-0" id="resultTitle">行业分析结果</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<!-- 图表容器 -->
<div id="valuationChart" style="width:100%; height:450px; min-height:350px;"></div>
</div>
<div class="col-md-4">
<!-- 估值分位数据 -->
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">行业估值分位数据</h6>
</div>
<div class="card-body">
<table class="table table-striped">
<tbody id="percentileTable">
<!-- 将通过JS动态填充 -->
</tbody>
</table>
</div>
</div>
<!-- 行业拥挤度 -->
<div class="card mt-3 d-none" id="crowdingStats">
<div class="card-header bg-info text-white">
<h6 class="mb-0">行业拥挤度指标</h6>
</div>
<div class="card-body">
<table class="table table-striped">
<tbody id="crowdingTable">
<!-- 将通过JS动态填充 -->
</tbody>
</table>
</div>
</div>
<div class="card mt-3">
<div class="card-header bg-info text-white">
<h6 class="mb-0">行业基本数据</h6>
</div>
<div class="card-body">
<table class="table table-striped">
<tbody id="industryStatsTable">
<!-- 将通过JS动态填充 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 拥挤度图表 -->
<div class="row mt-4 d-none" id="crowdingChartRow">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning">
<h6 class="mb-0 text-dark">行业交易拥挤度历史变化</h6>
</div>
<div class="card-body">
<div id="crowdingChart" style="width:100%; height:300px;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="alert alert-danger d-none" id="errorAlert">
<strong>错误:</strong> <span id="errorMessage"></span>
</div>
</div>
<!-- 引入Bootstrap JS (确保在jQuery之后) -->
<script src="../static/js/bootstrap.bundle.min.js"></script>
<!-- 引入行业分析JS -->
<script src="/static/js/industry.js"></script>
</body>
</html>

View File

@ -0,0 +1,119 @@
# 股票估值分析模块
这个模块用于分析单一股票的历史PE、PB分位数生成分析结果和可视化图表。
## 功能特点
- 获取单一股票的历史PE、PB数据
- 计算PE、PB的历史分位数
- 生成包含六根线的估值分析图:
- 股票PE/PB线
- 历史最大值线
- 历史最小值线
- 均值线
- 第一四分位线
- 第三四分位线
- 计算当前估值的百分位数
- 支持输出结果为图表和文本/JSON报告
## 使用方法
### 命令行方式
```bash
# 基本用法分析股票代码为601138的PE和PB
python -m src.valuation_analysis.cli --stock 601138
# 设置起始日期
python -m src.valuation_analysis.cli --stock 601138 --start-date 2020-01-01
# 仅分析PE
python -m src.valuation_analysis.cli --stock 601138 --metrics pe
# 保存为JSON格式
python -m src.valuation_analysis.cli --stock 601138 --format json
# 指定输出路径
python -m src.valuation_analysis.cli --stock 601138 --output my_analysis.json --format json
```
### 代码调用方式
```python
from src.valuation_analysis.pe_pb_analysis import ValuationAnalyzer
# 创建分析器
analyzer = ValuationAnalyzer()
# 分析单只股票
result = analyzer.analyze_stock_valuation('601138')
# 打印结果
print(f"股票代码: {result['stock_code']}")
print(f"股票名称: {result['stock_name']}")
print(f"当前PE: {result['metrics']['pe']['current']:.2f}")
print(f"PE百分位: {result['metrics']['pe']['percentile']:.2f}%")
print(f"PE图表: {result['metrics']['pe']['chart_path']}")
```
## 输出结果
### 图表输出
- PE分析图表保存在`results/valuation_analysis/`目录下
- PB分析图表保存在`results/valuation_analysis/`目录下
### 文本/JSON输出
分析结果包含以下信息:
- 股票基本信息(代码、名称)
- PE指标分析结果
- 当前PE值
- PE百分位数
- 历史最大/最小值
- 历史均值/中位数
- 第一/第三四分位数
- 图表路径
- PB指标分析结果
- 当前PB值
- PB百分位数
- 历史最大/最小值
- 历史均值/中位数
- 第一/第三四分位数
- 图表路径
## 后续开发计划
- 支持添加同行业平均PE和PB比较
- 支持批量分析多只股票
- 支持更多估值指标如PS、PCF等
- 添加定期运行和报告生成功能
## Web界面使用
现在估值分析功能也可以通过Web界面访问提供了友好的用户交互界面和图表展示
1. 启动Web服务器
```bash
python src/app.py
```
2. 在浏览器中访问:
```
http://localhost:5000
```
3. 在Web界面中
- 输入股票代码如SH603986
- 选择开始日期
- 选择估值指标PE或PB
- 可选择输入行业名称或概念板块名称进行对比
- 点击"分析"按钮获取结果
4. 结果展示:
- 股票估值历史走势图基于ECharts
- 估值分位数据表格
- 行业/概念统计信息(如适用)
Web界面使用了Bootstrap和ECharts提供响应式设计支持在各种设备上使用。

View File

@ -0,0 +1,6 @@
"""
股票估值分析模块
包含PEPB等估值指标的历史分位数分析
"""
from .pe_pb_analysis import *

View File

@ -0,0 +1,111 @@
"""
PE/PB估值分析命令行工具
使用方法:
python -m src.valuation_analysis.cli --stock 601138
"""
import argparse
import sys
import logging
import json
from pathlib import Path
from typing import Optional, List, Dict
from .pe_pb_analysis import ValuationAnalyzer, analyze_stock
from .config import DB_URL, OUTPUT_DIR
logger = logging.getLogger("valuation_analysis.cli")
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="股票PE/PB估值分析工具")
parser.add_argument('--stock', '-s', type=str, required=True,
help='股票代码例如601138')
parser.add_argument('--start-date', type=str, default='2018-01-01',
help='起始日期 (默认: 2018-01-01)')
parser.add_argument('--metrics', type=str, default='pe,pb',
help='分析指标,用逗号分隔 (默认: pe,pb)')
parser.add_argument('--output', '-o', type=str, default=None,
help='结果保存路径 (默认: results/valuation_analysis/)')
parser.add_argument('--format', type=str, choices=['json', 'text'], default='text',
help='输出格式 (默认: text)')
return parser.parse_args()
def main():
"""主函数"""
args = parse_args()
# 解析参数
stock_code = args.stock
start_date = args.start_date
metrics = args.metrics.split(',')
output_format = args.format
# 设置输出路径
output_path = args.output
if output_path is None:
output_path = OUTPUT_DIR / f"{stock_code}_valuation_analysis.{output_format}"
else:
output_path = Path(output_path)
# 运行分析
analyzer = ValuationAnalyzer()
result = analyzer.analyze_stock_valuation(stock_code, start_date, metrics)
# 输出结果
if not result['success']:
print(f"分析失败: {result.get('message', '未知错误')}")
return 1
# 打印分析结果
stock_name = result['stock_name']
analysis_date = result['analysis_date']
if output_format == 'json':
# 将图表路径转换为相对路径字符串
for metric in result['metrics']:
if 'chart_path' in result['metrics'][metric]:
result['metrics'][metric]['chart_path'] = str(result['metrics'][metric]['chart_path'])
# 写入JSON文件
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"分析结果已保存至: {output_path}")
else:
# 打印文本格式分析结果
print("\n" + "="*50)
print(f"股票代码: {stock_code}")
print(f"股票名称: {stock_name}")
print(f"分析日期: {analysis_date}")
print("="*50)
for metric in result['metrics']:
metric_data = result['metrics'][metric]
metric_name = "PE" if metric == "pe" else "PB"
print(f"\n{metric_name}分析结果:")
print("-"*30)
print(f"当前{metric_name}: {metric_data['current']:.2f}")
print(f"{metric_name}百分位: {metric_data['percentile']:.2f}%")
print(f"历史最小值: {metric_data['min']:.2f}")
print(f"历史最大值: {metric_data['max']:.2f}")
print(f"历史均值: {metric_data['mean']:.2f}")
print(f"历史中位数: {metric_data['median']:.2f}")
print(f"第一四分位数: {metric_data['q1']:.2f}")
print(f"第三四分位数: {metric_data['q3']:.2f}")
print(f"估值曲线图: {metric_data['chart_path']}")
print("\n" + "="*50)
print(f"分析完成,图表已保存")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,33 @@
"""
估值分析模块配置文件
"""
import os
from pathlib import Path
# 数据库配置
DB_CONFIG = {
'host': '192.168.18.199',
'port': 3306,
'user': 'root',
'password': 'Chlry#$.8',
'database': 'db_gp_cj'
}
# 创建数据库连接URL
DB_URL = f"mysql+pymysql://{DB_CONFIG['user']}:{DB_CONFIG['password']}@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}"
# 项目根目录
ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
# 结果输出目录
OUTPUT_DIR = ROOT_DIR / "results" / "valuation_analysis"
# 确保输出目录存在
os.makedirs(OUTPUT_DIR, exist_ok=True)
# 日志配置
LOG_FILE = ROOT_DIR / "logs" / "valuation_analysis.log"
# 确保日志目录存在
os.makedirs(LOG_FILE.parent, exist_ok=True)

View File

@ -0,0 +1,52 @@
"""
PE/PB分析模块使用示例
这个脚本演示了如何使用PE/PB分析模块分析单只股票的历史PE/PB分位数
"""
from .pe_pb_analysis import ValuationAnalyzer
def run_example():
"""运行示例"""
# 创建估值分析器实例
analyzer = ValuationAnalyzer()
# 分析股票
stock_code = 'SH603986' # 工业富联
result = analyzer.analyze_stock_valuation(stock_code,start_date='2024-01-01',industry_name='半导体')
# 打印分析结果
if not result['success']:
print(f"分析失败: {result.get('message', '未知错误')}")
return
print(f"\n股票代码: {result['stock_code']}")
print(f"股票名称: {result['stock_name']}")
print(f"分析日期: {result['analysis_date']}")
# 打印PE分析结果
if 'pe' in result['metrics']:
pe_data = result['metrics']['pe']
print("\nPE分析结果:")
print(f"当前PE: {pe_data['current']:.2f}")
print(f"PE百分位: {pe_data['percentile']:.2f}%")
print(f"历史最小值: {pe_data['min']:.2f}")
print(f"历史最大值: {pe_data['max']:.2f}")
print(f"历史均值: {pe_data['mean']:.2f}")
print(f"PE曲线图: {pe_data['chart_path']}")
# 打印PB分析结果
if 'pb' in result['metrics']:
pb_data = result['metrics']['pb']
print("\nPB分析结果:")
print(f"当前PB: {pb_data['current']:.2f}")
print(f"PB百分位: {pb_data['percentile']:.2f}%")
print(f"历史最小值: {pb_data['min']:.2f}")
print(f"历史最大值: {pb_data['max']:.2f}")
print(f"历史均值: {pb_data['mean']:.2f}")
print(f"PB曲线图: {pb_data['chart_path']}")
print("\n分析完成图表已保存到results/valuation_analysis/目录下")
if __name__ == "__main__":
run_example()

View File

@ -0,0 +1,486 @@
"""
行业估值分析模块
提供行业历史PEPBPS分位数分析功能以及行业拥挤度指标包括
1. 行业历史PEPBPS数据获取
2. 分位数计算
3. 行业交易拥挤度计算
"""
import pandas as pd
import numpy as np
from sqlalchemy import create_engine, text
import datetime
import logging
import json
import redis
import time
from typing import Tuple, Dict, List, Optional, Union
from .config import DB_URL, OUTPUT_DIR, LOG_FILE
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler()
]
)
logger = logging.getLogger("industry_analysis")
# 添加Redis客户端
redis_client = redis.Redis(
host='192.168.18.208', # Redis服务器地址根据实际情况调整
port=6379,
password='wlkj2018',
db=13,
socket_timeout=5,
decode_responses=True
)
class IndustryAnalyzer:
"""行业估值分析器类"""
def __init__(self, db_url: str = DB_URL):
"""
初始化行业估值分析器
Args:
db_url: 数据库连接URL
"""
self.engine = create_engine(
db_url,
pool_size=5,
max_overflow=10,
pool_recycle=3600
)
logger.info("行业估值分析器初始化完成")
def get_industry_list(self) -> List[Dict]:
"""
获取所有行业列表
Returns:
行业列表每个行业为一个字典包含code和name
"""
try:
query = text("""
SELECT DISTINCT bk_code, bk_name
FROM gp_hybk
ORDER BY bk_name
""")
with self.engine.connect() as conn:
result = conn.execute(query).fetchall()
if result:
return [{"code": str(row[0]), "name": row[1]} for row in result]
else:
logger.warning("未找到行业数据")
return []
except Exception as e:
logger.error(f"获取行业列表失败: {e}")
return []
def get_industry_stocks(self, industry_name: str) -> List[str]:
"""
获取指定行业的所有股票代码
Args:
industry_name: 行业名称
Returns:
股票代码列表
"""
try:
query = text("""
SELECT DISTINCT gp_code
FROM gp_hybk
WHERE bk_name = :industry_name
""")
with self.engine.connect() as conn:
result = conn.execute(query, {"industry_name": industry_name}).fetchall()
if result:
return [row[0] for row in result]
else:
logger.warning(f"未找到行业 {industry_name} 的股票")
return []
except Exception as e:
logger.error(f"获取行业股票失败: {e}")
return []
def get_industry_valuation_data(self, industry_name: str, start_date: str, metric: str = 'pe') -> pd.DataFrame:
"""
获取行业估值数据返回每日行业平均PE/PB/PS
说明
- 行业估值数据是指行业内所有股票的平均PE/PB/PS的历史数据
- 在计算过程中会剔除负值和极端值(如PE>1000)
Args:
industry_name: 行业名称
start_date: 开始日期
metric: 估值指标pepb或ps
Returns:
包含行业估值数据的DataFrame主要包含以下列
- timestamp: 日期
- avg_{metric}: 行业平均值
- stock_count: 参与计算的股票数量
"""
try:
# 验证metric参数
if metric not in ['pe', 'pb', 'ps']:
logger.error(f"不支持的估值指标: {metric}")
return pd.DataFrame()
# 获取行业所有股票
stock_codes = self.get_industry_stocks(industry_name)
if not stock_codes:
return pd.DataFrame()
# 构建查询 - 只计算每天的行业平均值和参与计算的股票数量
query = text(f"""
WITH valid_data AS (
SELECT
`timestamp`,
symbol,
{metric}
FROM
gp_day_data
WHERE
symbol IN :stock_codes AND
`timestamp` >= :start_date AND
{metric} > 0 AND
{metric} < 1000 -- 过滤掉极端异常值
)
SELECT
`timestamp`,
AVG({metric}) as avg_{metric},
COUNT(*) as stock_count
FROM
valid_data
GROUP BY
`timestamp`
ORDER BY
`timestamp`
""")
with self.engine.connect() as conn:
# 获取汇总数据
df = pd.read_sql(
query,
conn,
params={"stock_codes": tuple(stock_codes), "start_date": start_date}
)
if df.empty:
logger.warning(f"未找到行业 {industry_name} 的估值数据")
return pd.DataFrame()
logger.info(f"成功获取行业 {industry_name}{metric.upper()}数据,共 {len(df)} 条记录")
return df
except Exception as e:
logger.error(f"获取行业估值数据失败: {e}")
return pd.DataFrame()
def calculate_industry_percentiles(self, data: pd.DataFrame, metric: str = 'pe') -> Dict:
"""
计算行业估值指标的当前分位数及历史统计值
计算的是行业平均PE/PB/PS在其历史分布中的百分位以及历史最大值最小值四分位数等
Args:
data: 历史数据DataFrame
metric: 估值指标pepb或ps
Returns:
包含分位数信息的字典
"""
if data.empty:
logger.warning(f"数据为空,无法计算行业{metric}分位数")
return {}
# 验证metric参数
if metric not in ['pe', 'pb', 'ps']:
logger.error(f"不支持的估值指标: {metric}")
return {}
# 列名
avg_col = f'avg_{metric}'
# 获取最新值
latest_data = data.iloc[-1]
# 计算当前均值在历史分布中的百分位
# 使用 <= 是为了计算当前值在历史数据中的累积分布函数值
# 这样可以得到,有多少比例的历史数据小于等于当前值,即当前值的百分位
percentile = (data[avg_col] <= latest_data[avg_col]).mean() * 100
# 计算行业平均PE的历史最小值、最大值、四分位数等
min_value = float(data[avg_col].min())
max_value = float(data[avg_col].max())
mean_value = float(data[avg_col].mean())
median_value = float(data[avg_col].median())
q1_value = float(data[avg_col].quantile(0.25))
q3_value = float(data[avg_col].quantile(0.75))
# 计算各种分位数
result = {
'date': latest_data['timestamp'].strftime('%Y-%m-%d'),
'current': float(latest_data[avg_col]),
'min': min_value,
'max': max_value,
'mean': mean_value,
'median': median_value,
'q1': q1_value,
'q3': q3_value,
'percentile': float(percentile),
'stock_count': int(latest_data['stock_count'])
}
logger.info(f"计算行业 {metric} 分位数完成: 当前{metric}={result['current']:.2f}, 百分位={result['percentile']:.2f}%")
return result
def get_industry_crowding_index(self, industry_name: str, start_date: str = None, end_date: str = None) -> pd.DataFrame:
"""
计算行业交易拥挤度指标并使用Redis缓存结果
对于拥挤度指标固定使用3年数据不受start_date影响
缓存时间为1天
Args:
industry_name: 行业名称
start_date: 不再使用此参数保留是为了兼容性
end_date: 结束日期默认为当前日期
Returns:
包含行业拥挤度指标的DataFrame
"""
try:
# 始终使用3年前作为开始日期
three_years_ago = (datetime.datetime.now() - datetime.timedelta(days=3*365)).strftime('%Y-%m-%d')
if end_date is None:
end_date = datetime.datetime.now().strftime('%Y-%m-%d')
# 检查缓存
cache_key = f"industry_crowding:{industry_name}"
cached_data = redis_client.get(cache_key)
if cached_data:
try:
# 尝试解析缓存的JSON数据
cached_df_dict = json.loads(cached_data)
logger.info(f"从缓存获取行业 {industry_name} 的拥挤度数据")
# 将缓存的字典转换回DataFrame
df = pd.DataFrame(cached_df_dict)
# 确保trade_date列是日期类型
df['trade_date'] = pd.to_datetime(df['trade_date'])
return df
except Exception as cache_error:
logger.warning(f"解析缓存的拥挤度数据失败,将重新查询: {cache_error}")
# 获取行业所有股票
stock_codes = self.get_industry_stocks(industry_name)
if not stock_codes:
return pd.DataFrame()
# 优化方案分别查询市场总成交额和行业成交额然后在Python中计算比率
# 查询1获取每日总成交额
query_total = text("""
SELECT
`timestamp` AS trade_date,
SUM(amount) AS total_market_amount
FROM
gp_day_data
WHERE
`timestamp` BETWEEN :start_date AND :end_date
GROUP BY
`timestamp`
ORDER BY
`timestamp`
""")
# 查询2获取行业每日成交额
query_industry = text("""
SELECT
`timestamp` AS trade_date,
SUM(amount) AS industry_amount
FROM
gp_day_data
WHERE
symbol IN :stock_codes AND
`timestamp` BETWEEN :start_date AND :end_date
GROUP BY
`timestamp`
ORDER BY
`timestamp`
""")
with self.engine.connect() as conn:
# 执行两个独立的查询
df_total = pd.read_sql(
query_total,
conn,
params={"start_date": three_years_ago, "end_date": end_date}
)
df_industry = pd.read_sql(
query_industry,
conn,
params={
"stock_codes": tuple(stock_codes),
"start_date": three_years_ago,
"end_date": end_date
}
)
# 检查查询结果
if df_total.empty or df_industry.empty:
logger.warning(f"未找到行业 {industry_name} 的交易数据")
return pd.DataFrame()
# 在Python中合并数据并计算比率
df = pd.merge(df_total, df_industry, on='trade_date', how='inner')
# 计算行业成交额占比
df['industry_amount_ratio'] = (df['industry_amount'] / df['total_market_amount']) * 100
# 在Python中计算百分位
df['percentile'] = df['industry_amount_ratio'].rank(pct=True) * 100
# 添加拥挤度评级
df['crowding_level'] = pd.cut(
df['percentile'],
bins=[0, 20, 40, 60, 80, 100],
labels=['不拥挤', '较不拥挤', '中性', '较为拥挤', '极度拥挤']
)
# 将DataFrame转换为字典以便缓存
df_dict = df.to_dict(orient='records')
# 缓存结果有效期1天86400秒
try:
redis_client.set(
cache_key,
json.dumps(df_dict, default=str), # 使用default=str处理日期等特殊类型
ex=86400 # 1天的秒数
)
logger.info(f"已缓存行业 {industry_name} 的拥挤度数据有效期为1天")
except Exception as cache_error:
logger.warning(f"缓存行业拥挤度数据失败: {cache_error}")
logger.info(f"成功计算行业 {industry_name} 的拥挤度指标,共 {len(df)} 条记录")
return df
except Exception as e:
logger.error(f"计算行业拥挤度指标失败: {e}")
return pd.DataFrame()
def get_industry_analysis(self, industry_name: str, metric: str = 'pe', start_date: str = None) -> Dict:
"""
获取行业综合分析结果
Args:
industry_name: 行业名称
metric: 估值指标pepb或ps
start_date: 开始日期默认为3年前
Returns:
行业分析结果字典包含以下内容
- success: 是否成功
- industry_name: 行业名称
- metric: 估值指标
- analysis_date: 分析日期
- valuation: 估值数据包含
- dates: 日期列表
- avg_values: 行业平均值列表
- stock_counts: 参与计算的股票数量列表
- percentiles: 分位数信息包含行业平均值的历史最大值最小值四分位数等
- crowding如有: 拥挤度数据包含
- dates: 日期列表
- ratios: 拥挤度比例列表
- percentiles: 拥挤度百分位列表
- current: 当前拥挤度信息
"""
try:
# 默认查询近3年数据
if start_date is None:
start_date = (datetime.datetime.now() - datetime.timedelta(days=3*365)).strftime('%Y-%m-%d')
# 获取估值数据
valuation_data = self.get_industry_valuation_data(industry_name, start_date, metric)
if valuation_data.empty:
return {"success": False, "message": f"无法获取行业 {industry_name} 的估值数据"}
# 计算估值分位数
percentiles = self.calculate_industry_percentiles(valuation_data, metric)
if not percentiles:
return {"success": False, "message": f"无法计算行业 {industry_name} 的估值分位数"}
# 获取拥挤度指标始终使用3年数据不受start_date影响
crowding_data = self.get_industry_crowding_index(industry_name)
# 为了兼容前端,准备一些行业平均值的历史统计数据
avg_values = valuation_data[f'avg_{metric}'].tolist()
# 准备返回结果
result = {
"success": True,
"industry_name": industry_name,
"metric": metric.upper(),
"analysis_date": datetime.datetime.now().strftime('%Y-%m-%d'),
"valuation": {
"dates": valuation_data['timestamp'].dt.strftime('%Y-%m-%d').tolist(),
"avg_values": avg_values,
# 填充行业平均值的历史统计线
"min_values": [percentiles['min']] * len(avg_values), # 行业平均PE历史最小值
"max_values": [percentiles['max']] * len(avg_values), # 行业平均PE历史最大值
"q1_values": [percentiles['q1']] * len(avg_values), # 行业平均PE历史第一四分位数
"q3_values": [percentiles['q3']] * len(avg_values), # 行业平均PE历史第三四分位数
"median_values": [percentiles['median']] * len(avg_values), # 行业平均PE历史中位数
"stock_counts": valuation_data['stock_count'].tolist(),
"percentiles": percentiles
}
}
# 添加拥挤度数据(如果有)
if not crowding_data.empty:
current_crowding = crowding_data.iloc[-1]
result["crowding"] = {
"dates": crowding_data['trade_date'].dt.strftime('%Y-%m-%d').tolist(),
"ratios": crowding_data['industry_amount_ratio'].tolist(),
"percentiles": crowding_data['percentile'].tolist(),
"current": {
"date": current_crowding['trade_date'].strftime('%Y-%m-%d'),
"ratio": float(current_crowding['industry_amount_ratio']),
"percentile": float(current_crowding['percentile']),
"level": current_crowding['crowding_level'],
# 添加行业成交额比例的历史分位信息
"ratio_stats": {
"min": float(crowding_data['industry_amount_ratio'].min()),
"max": float(crowding_data['industry_amount_ratio'].max()),
"mean": float(crowding_data['industry_amount_ratio'].mean()),
"median": float(crowding_data['industry_amount_ratio'].median()),
"q1": float(crowding_data['industry_amount_ratio'].quantile(0.25)),
"q3": float(crowding_data['industry_amount_ratio'].quantile(0.75)),
}
}
}
return result
except Exception as e:
logger.error(f"获取行业综合分析失败: {e}")
return {"success": False, "message": f"获取行业综合分析失败: {e}"}

View File

@ -0,0 +1,614 @@
"""
股票PE/PB估值分析模块
提供股票历史PEPB分位数分析功能包括
1. 历史PEPB数据获取
2. 分位数计算
3. 可视化展示
4. 行业和概念板块对比分析
"""
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sqlalchemy import create_engine, text
import datetime
import logging
from typing import Tuple, Dict, List, Optional, Union
import os
import matplotlib.dates as mdates
from matplotlib.ticker import FuncFormatter
from pathlib import Path
from .config import DB_URL, OUTPUT_DIR, LOG_FILE
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler()
]
)
logger = logging.getLogger("valuation_analysis")
# 设置matplotlib中文字体
plt.rcParams['font.sans-serif'] = ['SimHei'] # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号
class ValuationAnalyzer:
"""股票估值分析器类"""
def __init__(self, db_url: str = DB_URL):
"""
初始化估值分析器
Args:
db_url: 数据库连接URL
"""
self.engine = create_engine(
db_url,
pool_size=5,
max_overflow=10,
pool_recycle=3600
)
logger.info("估值分析器初始化完成")
def get_stock_name(self, stock_code: str) -> str:
"""
根据股票代码获取股票名称
Args:
stock_code: 股票代码
Returns:
股票名称
"""
try:
# 查询数据库获取股票名称
query = text("""
SELECT DISTINCT gp_name
FROM gp_gnbk
WHERE gp_code = :stock_code
UNION
SELECT DISTINCT gp_name
FROM gp_hybk
WHERE gp_code = :stock_code
LIMIT 1
""")
with self.engine.connect() as conn:
result = conn.execute(query, {"stock_code": stock_code}).fetchone()
if result:
return result[0]
else:
logger.warning(f"未找到股票 {stock_code} 的名称信息")
return stock_code
except Exception as e:
logger.error(f"获取股票名称失败: {e}")
return stock_code
def get_industry_stocks(self, industry_name: str) -> List[str]:
"""
获取指定行业的所有股票代码
Args:
industry_name: 行业名称
Returns:
股票代码列表
"""
try:
query = text("""
SELECT DISTINCT gp_code
FROM gp_hybk
WHERE bk_name = :industry_name
""")
with self.engine.connect() as conn:
result = conn.execute(query, {"industry_name": industry_name}).fetchall()
if result:
return [row[0] for row in result]
else:
logger.warning(f"未找到行业 {industry_name} 的股票")
return []
except Exception as e:
logger.error(f"获取行业股票失败: {e}")
return []
def get_concept_stocks(self, concept_name: str) -> List[str]:
"""
获取指定概念板块的所有股票代码
Args:
concept_name: 概念板块名称
Returns:
股票代码列表
"""
try:
query = text("""
SELECT DISTINCT gp_code
FROM gp_gnbk
WHERE bk_name = :concept_name
""")
with self.engine.connect() as conn:
result = conn.execute(query, {"concept_name": concept_name}).fetchall()
if result:
return [row[0] for row in result]
else:
logger.warning(f"未找到概念板块 {concept_name} 的股票")
return []
except Exception as e:
logger.error(f"获取概念板块股票失败: {e}")
return []
def get_industry_avg_data(self, industry_name: str, start_date: str, metric: str) -> pd.DataFrame:
"""
获取行业平均估值数据
Args:
industry_name: 行业名称
start_date: 开始日期
metric: 估值指标pe或pb
Returns:
包含行业平均估值数据的DataFrame
"""
try:
# 获取行业所有股票
stock_codes = self.get_industry_stocks(industry_name)
if not stock_codes:
return pd.DataFrame()
# 构建查询
query = text(f"""
WITH valid_data AS (
SELECT
`timestamp`,
symbol,
{metric}
FROM
gp_day_data
WHERE
symbol IN :stock_codes AND
`timestamp` >= :start_date AND
{metric} > 0 AND
{metric} < 1000 -- 过滤掉极端异常值
)
SELECT
`timestamp`,
AVG({metric}) as avg_{metric},
COUNT(*) as stock_count
FROM
valid_data
GROUP BY
`timestamp`
ORDER BY
`timestamp`
""")
with self.engine.connect() as conn:
df = pd.read_sql(
query,
conn,
params={"stock_codes": tuple(stock_codes), "start_date": start_date}
)
if df.empty:
logger.warning(f"未找到行业 {industry_name} 的估值数据")
return pd.DataFrame()
logger.info(f"成功获取行业 {industry_name} 的平均{metric.upper()}数据,共 {len(df)} 条记录")
return df
except Exception as e:
logger.error(f"获取行业平均数据失败: {e}")
return pd.DataFrame()
def get_concept_avg_data(self, concept_name: str, start_date: str, metric: str) -> pd.DataFrame:
"""
获取概念板块平均估值数据
Args:
concept_name: 概念板块名称
start_date: 开始日期
metric: 估值指标pe或pb
Returns:
包含概念板块平均估值数据的DataFrame
"""
try:
# 获取概念板块所有股票
stock_codes = self.get_concept_stocks(concept_name)
if not stock_codes:
return pd.DataFrame()
# 构建查询
query = text(f"""
WITH valid_data AS (
SELECT
`timestamp`,
symbol,
{metric}
FROM
gp_day_data
WHERE
symbol IN :stock_codes AND
`timestamp` >= :start_date AND
{metric} > 0 AND
{metric} < 1000 -- 过滤掉极端异常值
)
SELECT
`timestamp`,
AVG({metric}) as avg_{metric},
COUNT(*) as stock_count
FROM
valid_data
GROUP BY
`timestamp`
ORDER BY
`timestamp`
""")
with self.engine.connect() as conn:
df = pd.read_sql(
query,
conn,
params={"stock_codes": tuple(stock_codes), "start_date": start_date}
)
if df.empty:
logger.warning(f"未找到概念板块 {concept_name} 的估值数据")
return pd.DataFrame()
logger.info(f"成功获取概念板块 {concept_name} 的平均{metric.upper()}数据,共 {len(df)} 条记录")
return df
except Exception as e:
logger.error(f"获取概念板块平均数据失败: {e}")
return pd.DataFrame()
def get_historical_data(self, stock_code: str, start_date: str = '2018-01-01') -> pd.DataFrame:
"""
获取股票的历史PEPB数据
Args:
stock_code: 股票代码
start_date: 开始日期默认为2018-01-01
Returns:
包含历史PEPB和价格数据的DataFrame
"""
try:
query = text("""
SELECT
`timestamp`, `close`, `pe`, `pb`
FROM
gp_day_data
WHERE
symbol = :symbol AND
`timestamp` >= :start_date AND
pe > 0 AND pb > 0
ORDER BY
`timestamp`
""")
with self.engine.connect() as conn:
df = pd.read_sql(
query,
conn,
params={"symbol": stock_code, "start_date": start_date}
)
if df.empty:
logger.warning(f"未找到股票 {stock_code} 的历史数据")
return pd.DataFrame()
# 转换数据类型
df['close'] = df['close'].astype(float)
df['pe'] = df['pe'].astype(float)
df['pb'] = df['pb'].astype(float)
# 过滤异常值(可能有极端值导致图表不可读)
pe_q1, pe_q3 = df['pe'].quantile(0.05), df['pe'].quantile(0.95)
pe_iqr = pe_q3 - pe_q1
pe_upper = pe_q3 + 1.5 * pe_iqr
pb_q1, pb_q3 = df['pb'].quantile(0.05), df['pb'].quantile(0.95)
pb_iqr = pb_q3 - pb_q1
pb_upper = pb_q3 + 1.5 * pb_iqr
# 过滤异常值但保留记录
df['pe_filtered'] = np.where(df['pe'] > pe_upper, pe_upper, df['pe'])
df['pb_filtered'] = np.where(df['pb'] > pb_upper, pb_upper, df['pb'])
logger.info(f"成功获取到股票 {stock_code} 的历史数据,共 {len(df)} 条记录")
return df
except Exception as e:
logger.error(f"获取历史数据失败: {e}")
return pd.DataFrame()
def calculate_percentiles(self, data: pd.DataFrame, metric: str = 'pe') -> Dict:
"""
计算估值指标的分位数
Args:
data: 历史数据DataFrame
metric: 估值指标pe或pb
Returns:
包含分位数信息的字典
"""
if data.empty:
logger.warning(f"数据为空,无法计算{metric}分位数")
return {}
# 使用过滤后的数据计算分位数
metric_filtered = f'{metric}_filtered'
if metric_filtered not in data.columns:
metric_filtered = metric
# 计算各种分位数
result = {
'min': data[metric_filtered].min(),
'max': data[metric_filtered].max(),
'current': data[metric_filtered].iloc[-1],
'mean': data[metric_filtered].mean(),
'median': data[metric_filtered].median(),
'q1': data[metric_filtered].quantile(0.25), # 第一四分位数
'q3': data[metric_filtered].quantile(0.75), # 第三四分位数
}
# 计算当前值的百分位
result['percentile'] = (data[metric_filtered] <= result['current']).mean() * 100
logger.info(f"计算 {metric} 分位数完成: 当前{metric}={result['current']:.2f}, 百分位={result['percentile']:.2f}%")
return result
def plot_valuation_bands(
self,
data: pd.DataFrame,
stock_code: str,
metric: str = 'pe',
industry_name: Optional[str] = None,
concept_name: Optional[str] = None,
save_path: Optional[str] = None
) -> str:
"""
绘制估值分位数曲线图
Args:
data: 历史数据DataFrame
stock_code: 股票代码
metric: 估值指标pe或pb
industry_name: 行业名称可选
concept_name: 概念板块名称可选
save_path: 保存路径如果为None则使用默认路径
Returns:
图表保存路径
"""
if data.empty:
logger.warning(f"数据为空,无法绘制{metric}分位数曲线图")
return ""
stock_name = self.get_stock_name(stock_code)
# 使用过滤后的数据绘图
metric_filtered = f'{metric}_filtered'
if metric_filtered not in data.columns:
metric_filtered = metric
# 计算分位数
percentiles = self.calculate_percentiles(data, metric)
# 创建图形和子图
fig, ax1 = plt.subplots(figsize=(12, 8))
# 绘制估值曲线(左轴)
ax1.plot(data['timestamp'], data[metric_filtered], color='black', linewidth=1.5, label=f'{stock_name} {metric.upper()}')
# 获取并绘制行业平均数据
industry_data = None
if industry_name:
industry_data = self.get_industry_avg_data(industry_name, data['timestamp'].min().strftime('%Y-%m-%d'), metric)
if not industry_data.empty:
ax1.plot(industry_data['timestamp'], industry_data[f'avg_{metric}'],
color='blue', linewidth=1.5, label=f'{industry_name}行业平均{metric.upper()}')
# 在图表中添加行业统计信息
industry_min_count = industry_data['stock_count'].min()
industry_max_count = industry_data['stock_count'].max()
industry_avg_count = industry_data['stock_count'].mean()
logger.info(f"行业 {industry_name} 股票数量统计: 最小={industry_min_count}, 最大={industry_max_count}, 平均={industry_avg_count:.1f}")
# 获取并绘制概念板块平均数据
concept_data = None
if concept_name:
concept_data = self.get_concept_avg_data(concept_name, data['timestamp'].min().strftime('%Y-%m-%d'), metric)
if not concept_data.empty:
ax1.plot(concept_data['timestamp'], concept_data[f'avg_{metric}'],
color='red', linewidth=1.5, label=f'{concept_name}概念平均{metric.upper()}')
# 在图表中添加概念板块统计信息
concept_min_count = concept_data['stock_count'].min()
concept_max_count = concept_data['stock_count'].max()
concept_avg_count = concept_data['stock_count'].mean()
logger.info(f"概念 {concept_name} 股票数量统计: 最小={concept_min_count}, 最大={concept_max_count}, 平均={concept_avg_count:.1f}")
# 绘制分位数线
ax1.axhline(y=percentiles['min'], color='green', linestyle='-', alpha=0.7, label=f'最小{metric.upper()}')
ax1.axhline(y=percentiles['max'], color='red', linestyle='-', alpha=0.7, label=f'最大{metric.upper()}')
ax1.axhline(y=percentiles['mean'], color='purple', linestyle='--', alpha=0.7, label=f'{metric.upper()}均值')
ax1.axhline(y=percentiles['q1'], color='orange', linestyle='--', alpha=0.7, label=f'第一四分位数')
ax1.axhline(y=percentiles['q3'], color='brown', linestyle='--', alpha=0.7, label=f'第三四分位数')
# 设置图表标题和标签
metric_name = "PE" if metric == "pe" else "PB"
current_percentile = percentiles['percentile']
title = f"{stock_code} {stock_name} 历史{metric_name}分位数分析"
if industry_name:
title += f" vs {industry_name}行业"
if concept_name:
title += f" vs {concept_name}概念"
title += f" (当前{metric_name}百分位: {current_percentile:.2f}%)"
plt.title(title, fontsize=14)
ax1.set_xlabel('日期', fontsize=12)
ax1.set_ylabel(f'{metric_name}', fontsize=12)
# 设置日期格式
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
ax1.xaxis.set_major_locator(mdates.MonthLocator(interval=6))
fig.autofmt_xdate()
# 设置图例
lines1, labels1 = ax1.get_legend_handles_labels()
ax1.legend(lines1, labels1, loc='upper left')
# 添加网格线
ax1.grid(True, linestyle='--', alpha=0.6)
# 添加当前估值的标注
current_value = percentiles['current']
info_text = f'当前{metric_name}: {current_value:.2f}\n' \
f'历史区间: [{percentiles["min"]:.2f}, {percentiles["max"]:.2f}]\n' \
f'均值: {percentiles["mean"]:.2f}\n' \
f'百分位: {current_percentile:.2f}%'
# 添加行业/概念统计信息
if industry_data is not None and not industry_data.empty:
info_text += f'\n\n{industry_name}行业统计:\n' \
f'成分股数: {industry_min_count}-{industry_max_count}\n' \
f'均值: {industry_data[f"avg_{metric}"].mean():.2f}'
if concept_data is not None and not concept_data.empty:
info_text += f'\n\n{concept_name}概念统计:\n' \
f'成分股数: {concept_min_count}-{concept_max_count}\n' \
f'均值: {concept_data[f"avg_{metric}"].mean():.2f}'
ax1.text(
0.02, 0.05,
info_text,
transform=ax1.transAxes,
fontsize=10,
bbox=dict(facecolor='white', alpha=0.8, edgecolor='gray')
)
# 保存图表
if save_path is None:
filename = f"{stock_code}_{metric}_analysis_{datetime.datetime.now().strftime('%Y%m%d')}.png"
save_path = os.path.join(OUTPUT_DIR, filename)
plt.tight_layout()
plt.savefig(save_path, dpi=300)
plt.close()
logger.info(f"估值分位数曲线图已保存至: {save_path}")
return save_path
def analyze_stock_valuation(
self,
stock_code: str,
start_date: str = '2018-01-01',
metrics: List[str] = ['pe', 'pb'],
industry_name: Optional[str] = None,
concept_name: Optional[str] = None
) -> Dict:
"""
分析股票估值情况
Args:
stock_code: 股票代码
start_date: 开始日期
metrics: 估值指标列表
industry_name: 行业名称可选
concept_name: 概念板块名称可选
Returns:
分析结果字典
"""
logger.info(f"开始分析股票 {stock_code} 的估值情况")
# 获取历史数据
data = self.get_historical_data(stock_code, start_date)
if data.empty:
logger.error(f"无法获取股票 {stock_code} 的历史数据,分析终止")
return {
'success': False,
'message': f"无法获取股票 {stock_code} 的历史数据"
}
result = {
'success': True,
'stock_code': stock_code,
'stock_name': self.get_stock_name(stock_code),
'analysis_date': datetime.datetime.now().strftime('%Y-%m-%d'),
'metrics': {}
}
# 分析各个估值指标
for metric in metrics:
if metric not in ['pe', 'pb']:
logger.warning(f"不支持的估值指标: {metric},跳过")
continue
# 计算分位数
percentiles = self.calculate_percentiles(data, metric)
if not percentiles:
logger.warning(f"无法计算股票 {stock_code}{metric} 分位数")
continue
# 绘制图表
chart_path = self.plot_valuation_bands(
data,
stock_code,
metric,
industry_name,
concept_name
)
# 添加到结果中
result['metrics'][metric] = {
'current': percentiles['current'],
'percentile': percentiles['percentile'],
'min': percentiles['min'],
'max': percentiles['max'],
'mean': percentiles['mean'],
'median': percentiles['median'],
'q1': percentiles['q1'],
'q3': percentiles['q3'],
'chart_path': chart_path
}
logger.info(f"股票 {stock_code} 的估值分析完成")
return result
def analyze_stock(
stock_code: str,
db_url: Optional[str] = None,
industry_name: Optional[str] = None,
concept_name: Optional[str] = None
) -> Dict:
"""
分析单个股票的估值情况
Args:
stock_code: 股票代码
db_url: 数据库连接URL
industry_name: 行业名称可选
concept_name: 概念板块名称可选
Returns:
分析结果字典
"""
analyzer = ValuationAnalyzer(db_url) if db_url else ValuationAnalyzer()
return analyzer.analyze_stock_valuation(stock_code, industry_name=industry_name, concept_name=concept_name)