commit;
This commit is contained in:
parent
aadef5f0fa
commit
0920615bbd
|
@ -0,0 +1,17 @@
|
|||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env
|
||||
venv
|
||||
.env
|
||||
*.log
|
||||
logs/
|
||||
reports/
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
|
@ -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"]
|
|
@ -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;
|
|
@ -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' 查看所有实例状态"
|
|
@ -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' 查看所有实例状态"
|
|
@ -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
|
|
@ -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:
|
|
@ -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
|
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
flask==2.0.3
|
||||
werkzeug==2.0.3
|
||||
flask-cors==3.0.10
|
||||
sqlalchemy==1.4.46
|
||||
sqlalchemy==2.0.40
|
||||
pymysql==1.0.3
|
||||
tqdm>=4.65.0
|
||||
easy-spider-tool>=0.0.4
|
||||
|
@ -15,3 +15,4 @@ reportlab>=4.3.1
|
|||
markdown2>=2.5.3
|
||||
google-genai
|
||||
redis==5.2.1
|
||||
pandas==2.2.3
|
|
@ -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 回测结束!
|
|
@ -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 "回测结束!"
|
150
src/API.md
150
src/API.md
|
@ -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 响应:
|
||||
|
@ -830,3 +979,4 @@ curl -X POST http://localhost:5000/api/comprehensive_analysis \
|
|||
}
|
||||
}'
|
||||
```
|
||||
|
||||
|
|
|
@ -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())
|
580
src/app.py
580
src/app.py
|
@ -5,13 +5,13 @@ import pandas as pd
|
|||
import uuid
|
||||
import json
|
||||
from threading import Thread
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from src.fundamentals_llm.fundamental_analysis_database import get_analysis_result, get_db
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
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
|
||||
import logging
|
||||
|
||||
|
@ -21,6 +21,12 @@ from src.fundamentals_llm.enterprise_screener import EnterpriseScreener
|
|||
# 导入股票回测器
|
||||
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(
|
||||
level=logging.INFO,
|
||||
|
@ -47,8 +53,11 @@ CORS(app) # 启用跨域请求支持
|
|||
# 创建企业筛选器实例
|
||||
screener = EnterpriseScreener()
|
||||
|
||||
# 获取数据库连接
|
||||
db = next(get_db())
|
||||
# 创建估值分析器实例
|
||||
valuation_analyzer = ValuationAnalyzer()
|
||||
|
||||
# 创建行业分析器实例
|
||||
industry_analyzer = IndustryAnalyzer()
|
||||
|
||||
# 获取项目根目录
|
||||
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)
|
||||
logger.error(f"回测任务 {task_id} 失败:{str(e)}")
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""渲染主页"""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/api/backtest/run', methods=['POST'])
|
||||
def start_backtest():
|
||||
"""启动回测任务
|
||||
|
@ -854,7 +868,6 @@ def analyze_and_recommend():
|
|||
"message": f"分析和推荐股票失败: {str(e)}"
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/comprehensive_analysis', methods=['POST'])
|
||||
def comprehensive_analysis():
|
||||
"""综合分析接口 - 使用队列方式处理被锁定的股票
|
||||
|
@ -967,24 +980,6 @@ def comprehensive_analysis():
|
|||
# 处理当前队列中的所有股票
|
||||
for stock_code, stock_name in processing_queue:
|
||||
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"):
|
||||
# 已被锁定,放到下一轮队列
|
||||
|
@ -1175,14 +1170,42 @@ def comprehensive_analysis():
|
|||
# 获取传入的所有股票代码
|
||||
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:
|
||||
if code in input_stock_codes:
|
||||
# 获取各个维度的分析结果
|
||||
investment_advice_result = get_analysis_result(db, code, "investment_advice")
|
||||
industry_competition_result = get_analysis_result(db, code, "industry_competition")
|
||||
financial_report_result = get_analysis_result(db, code, "financial_report")
|
||||
valuation_level_result = get_analysis_result(db, code, "valuation_level")
|
||||
investment_advice_result = get_analysis_result(db_session, code, "investment_advice")
|
||||
industry_competition_result = get_analysis_result(db_session, code, "industry_competition")
|
||||
financial_report_result = get_analysis_result(db_session, code, "financial_report")
|
||||
valuation_level_result = get_analysis_result(db_session, code, "valuation_level")
|
||||
|
||||
# 从ai_response和extra_info中提取所需的值
|
||||
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获取)
|
||||
"industry_space": industry_space, # 行业发展空间(2:高速增长, 1:稳定经营, 0:不确定性大, -1:不利经营)
|
||||
"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)} 个符合条件的股票")
|
||||
|
@ -1204,6 +1228,9 @@ def comprehensive_analysis():
|
|||
except Exception as e:
|
||||
logger.error(f"应用企业画像筛选失败: {str(e)}")
|
||||
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")
|
||||
|
@ -1246,5 +1273,502 @@ def comprehensive_analysis():
|
|||
"message": f"综合分析失败: {str(e)}"
|
||||
}), 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__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
|
@ -121,7 +121,7 @@ class ChatBot:
|
|||
"role": "user",
|
||||
"content": user_input
|
||||
})
|
||||
|
||||
extra_body = {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
|
||||
# 调用OpenAI API(流式输出)
|
||||
stream = self.client.chat.completions.create(
|
||||
|
@ -132,6 +132,7 @@ class ChatBot:
|
|||
max_tokens=max_tokens,
|
||||
frequency_penalty=frequency_penalty,
|
||||
stream=True,
|
||||
extra_body=extra_body,
|
||||
timeout=600
|
||||
)
|
||||
|
||||
|
|
|
@ -139,6 +139,12 @@ class EnterpriseScreener:
|
|||
'operator': '>=',
|
||||
'value': -1
|
||||
},
|
||||
{
|
||||
'dimension': 'industry_competition',
|
||||
'field': 'industry_space',
|
||||
'operator': '>=',
|
||||
'value': 0
|
||||
},
|
||||
]
|
||||
return self._screen_stocks_by_conditions(conditions, limit)
|
||||
|
||||
|
|
|
@ -85,7 +85,7 @@ redis_client = redis.Redis(
|
|||
host='192.168.18.208', # Redis服务器地址,根据实际情况调整
|
||||
port=6379,
|
||||
password='wlkj2018',
|
||||
db=14,
|
||||
db=13,
|
||||
socket_timeout=5,
|
||||
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="GLM")
|
||||
self.db = next(get_db())
|
||||
|
||||
# 定义维度映射
|
||||
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]]:
|
||||
db_session1 = next(get_db())
|
||||
"""查询分析结果,如果不存在则生成新的分析
|
||||
|
||||
Args:
|
||||
|
@ -139,7 +139,7 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
# 如果存在结果,直接返回
|
||||
|
@ -149,10 +149,10 @@ class FundamentalAnalyzer:
|
|||
# 如果不存在,生成新的分析
|
||||
logger.info(f"数据库中未找到 {stock_name}({stock_code}) 的 {dimension} 分析结果,开始生成")
|
||||
success = self.dimension_methods[dimension](stock_code, stock_name)
|
||||
|
||||
db_session2 = next(get_db())
|
||||
if success:
|
||||
# 重新查询数据库获取结果
|
||||
result = get_analysis_result(self.db, stock_code, dimension)
|
||||
result = get_analysis_result(db_session2, stock_code, dimension)
|
||||
if result:
|
||||
return True, result.ai_response, result.reasoning_process, result.references
|
||||
|
||||
|
@ -161,6 +161,11 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"查询分析结果失败: {str(e)}")
|
||||
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:
|
||||
"""从响应中移除参考资料部分
|
||||
|
@ -181,6 +186,7 @@ class FundamentalAnalyzer:
|
|||
def analyze_company_profile(self, stock_code: str, stock_name: str) -> bool:
|
||||
"""分析公司简介"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 构建提示词
|
||||
prompt = f"""请对{stock_name}({stock_code})进行公司简介分析,严格要求输出控制在500字以内(主营业务介绍300字,成立背景与历程200字),请严格按照以下格式输出:
|
||||
|
||||
|
@ -201,7 +207,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
return save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="company_profile",
|
||||
|
@ -213,10 +219,14 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析公司简介失败: {str(e)}")
|
||||
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:
|
||||
"""分析实控人和管理层持股情况"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})的实控人和管理层持股情况进行简要分析,要求输出控制在300字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 实控人情况:
|
||||
|
@ -234,7 +244,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="management_ownership",
|
||||
|
@ -253,6 +263,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析实控人和管理层持股情况失败: {str(e)}")
|
||||
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]:
|
||||
"""从实控人和管理层持股分析中提取持股情况和能力评价并更新数据库
|
||||
|
@ -266,6 +279,7 @@ class FundamentalAnalyzer:
|
|||
Dict[str, int]: 包含shareholding和ability的字典
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 提取持股情况
|
||||
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)
|
||||
|
||||
# 更新数据库中的记录
|
||||
result = get_analysis_result(self.db, stock_code, "management_ownership")
|
||||
result = get_analysis_result(db_session, stock_code, "management_ownership")
|
||||
if result:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="management_ownership",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -295,6 +309,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取实控人和管理层信息失败: {str(e)}")
|
||||
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:
|
||||
"""从实控人和管理层持股分析中提取持股减持情况
|
||||
|
@ -399,6 +416,7 @@ class FundamentalAnalyzer:
|
|||
def analyze_financial_report(self, stock_code: str, stock_name: str) -> bool:
|
||||
"""分析企业财报情况"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})的财报情况进行简要分析,严格要求最新财报情况200字以内,最新业绩预告情况100字以内,近三年变化趋势150字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 最新财报情况
|
||||
|
@ -425,7 +443,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="financial_report",
|
||||
|
@ -444,6 +462,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析财报情况失败: {str(e)}")
|
||||
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:
|
||||
"""从财报分析中提取财报水平评级并更新数据库
|
||||
|
@ -457,6 +478,7 @@ class FundamentalAnalyzer:
|
|||
int: 财报水平评级 (2:边际向好无风险, 1:稳定风险小, 0:稳定有隐患, -1:波动大有隐患, -2:波动大隐患大)
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 使用在线模型分析财报水平
|
||||
prompt = f"""请对{stock_name}({stock_code})的财报水平进行专业分析,并返回对应的数值评级:
|
||||
|
||||
|
@ -472,8 +494,9 @@ class FundamentalAnalyzer:
|
|||
请仅返回一个数值:2、1、0、-1或-2,不要包含任何解释或说明。"""
|
||||
|
||||
# 使用在线模型进行分析
|
||||
response = self.chat_bot.chat(prompt)
|
||||
full_response = response["response"].strip()
|
||||
response = self.offline_bot_tl_qw.chat(prompt,temperature=0)
|
||||
full_response = response
|
||||
# full_response = response["response"].strip()
|
||||
|
||||
# 提取数值
|
||||
report_level = 0 # 默认值
|
||||
|
@ -492,10 +515,10 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="financial_report",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -513,10 +536,14 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取财报水平评级失败: {str(e)}")
|
||||
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:
|
||||
"""分析行业发展趋势和竞争格局"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})所在行业的发展趋势和竞争格局进行简要分析,要求输出控制在400字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 市场需求:
|
||||
|
@ -544,7 +571,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="industry_competition",
|
||||
|
@ -563,6 +590,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析行业发展趋势和竞争格局失败: {str(e)}")
|
||||
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:
|
||||
"""从行业发展趋势和竞争格局中提取行业发展空间并更新数据库
|
||||
|
@ -576,6 +606,7 @@ class FundamentalAnalyzer:
|
|||
int: 行业发展空间值 (2:高速增长, 1:稳定经营, 0:不确定性大, -1:不利经营)
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 使用离线模型分析行业发展空间
|
||||
prompt = f"""请分析以下{stock_name}({stock_code})的行业发展趋势和竞争格局文本,评估当前市场环境、阶段和竞争格局对企业未来的影响,并返回对应的数值:
|
||||
- 如果当前市场环境、阶段和竞争格局符合未来企业高速增长,返回数值"2"
|
||||
|
@ -605,10 +636,10 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="industry_competition",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -626,10 +657,14 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取行业发展空间失败: {str(e)}")
|
||||
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:
|
||||
"""分析近期重大订单和项目进展"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})的近期重大订单和项目进展进行简要分析,要求输出控制在500字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 主要业务领域进展(300字左右):
|
||||
|
@ -649,7 +684,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="recent_projects",
|
||||
|
@ -668,6 +703,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析近期重大订单和项目进展失败: {str(e)}")
|
||||
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:
|
||||
"""从重大订单和项目进展中提取进展情况并更新数据库
|
||||
|
@ -681,6 +719,7 @@ class FundamentalAnalyzer:
|
|||
int: 项目进展评价 (1:超预期, 0:顺利但未超预期, -1:不顺利或在验证中)
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 使用离线模型分析项目进展情况
|
||||
prompt = f"""请分析以下{stock_name}({stock_code})的重大订单和项目进展情况,并返回对应的数值:
|
||||
- 如果项目进展顺利,且订单交付/建厂等超预期,返回数值"1"
|
||||
|
@ -710,10 +749,10 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="recent_projects",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -731,10 +770,14 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取重大项目进展评价失败: {str(e)}")
|
||||
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:
|
||||
"""分析股吧讨论内容"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})的股吧讨论内容进行简要分析,要求输出控制在400字以内(主要讨论话题200字,重要信息汇总200字),请严格按照以下格式输出:
|
||||
|
||||
1. 主要讨论话题:
|
||||
|
@ -753,7 +796,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="stock_discussion",
|
||||
|
@ -772,6 +815,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析股吧讨论内容失败: {str(e)}")
|
||||
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:
|
||||
"""从股吧讨论内容中提取市场情绪并更新数据库
|
||||
|
@ -785,6 +831,7 @@ class FundamentalAnalyzer:
|
|||
int: 市场情绪值 (1:乐观, 0:中性, -1:悲观)
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 使用离线模型分析市场情绪
|
||||
prompt = f"""请分析以下{stock_name}({stock_code})的股吧讨论内容分析,判断整体市场情绪倾向,并返回对应的数值:
|
||||
- 如果股吧讨论情绪偏乐观,返回数值"1"
|
||||
|
@ -815,10 +862,10 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="stock_discussion",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -836,10 +883,14 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取股吧情绪值失败: {str(e)}")
|
||||
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:
|
||||
"""分析产业链上下游合作动态"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})最近半年内的产业链上下游合作动态进行简要分析,要求输出控制在400字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 重要客户合作(200字左右):
|
||||
|
@ -858,7 +909,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="industry_cooperation",
|
||||
|
@ -877,6 +928,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析产业链上下游合作动态失败: {str(e)}")
|
||||
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:
|
||||
"""从产业链上下游合作动态中提取合作动态质量评级并更新数据库
|
||||
|
@ -890,6 +944,7 @@ class FundamentalAnalyzer:
|
|||
int: 合作动态质量值 (2:质量高, 1:一般, 0:无/质量低, -1:负面)
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 使用在线模型分析合作动态质量
|
||||
prompt = f"""请评估{stock_name}({stock_code})的产业链上下游合作动态质量,并返回相应数值:
|
||||
|
||||
|
@ -924,10 +979,10 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="industry_cooperation",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -945,10 +1000,14 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取产业链合作动态质量失败: {str(e)}")
|
||||
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:
|
||||
"""分析券商和研究机构目标股价"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})的券商和研究机构目标股价进行简要分析,要求输出控制在300字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 目标股价情况(150字左右):
|
||||
|
@ -966,7 +1025,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="target_price",
|
||||
|
@ -985,6 +1044,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析目标股价失败: {str(e)}")
|
||||
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]:
|
||||
"""从目标股价分析中提取券商评级和上涨/下跌空间并更新数据库
|
||||
|
@ -998,15 +1060,16 @@ class FundamentalAnalyzer:
|
|||
Dict[str, int]: 包含securities_rating和odds的字典
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 提取券商评级和上涨/下跌空间
|
||||
securities_rating = self._extract_securities_rating(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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="target_price",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -1025,6 +1088,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取目标股价信息失败: {str(e)}")
|
||||
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:
|
||||
"""从目标股价分析中提取券商评级
|
||||
|
@ -1116,6 +1182,7 @@ class FundamentalAnalyzer:
|
|||
def analyze_valuation_level(self, stock_code: str, stock_name: str) -> bool:
|
||||
"""分析企业PE和PB在历史分位水平和行业平均水平的对比情况"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
prompt = f"""请对{stock_name}({stock_code})的估值水平进行简要分析,要求输出控制在300字以内,请严格按照以下格式输出:
|
||||
|
||||
1. 历史估值水平(150字左右):
|
||||
|
@ -1134,7 +1201,7 @@ class FundamentalAnalyzer:
|
|||
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="valuation_level",
|
||||
|
@ -1153,6 +1220,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"分析估值水平失败: {str(e)}")
|
||||
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]:
|
||||
"""从估值水平分析中提取历史和行业估值分类并更新数据库
|
||||
|
@ -1170,6 +1240,7 @@ class FundamentalAnalyzer:
|
|||
- pb_industry: PB行业对比分类 (-1:高于行业, 0:接近行业, 1:低于行业)
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 直接提取四个分类值
|
||||
pe_historical = self._extract_pe_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)
|
||||
|
||||
# 更新数据库中的记录
|
||||
result = get_analysis_result(self.db, stock_code, "valuation_level")
|
||||
result = get_analysis_result(db_session, stock_code, "valuation_level")
|
||||
if result:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="valuation_level",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -1211,6 +1282,9 @@ class FundamentalAnalyzer:
|
|||
"pe_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:
|
||||
"""从估值水平分析中提取PE历史分位分类
|
||||
|
@ -1375,17 +1449,18 @@ class FundamentalAnalyzer:
|
|||
def generate_investment_advice(self, stock_code: str, stock_name: str) -> bool:
|
||||
"""生成最终投资建议"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 收集所有维度的分析结果(排除investment_advice)
|
||||
all_results = {}
|
||||
analysis_dimensions = [dim for dim in self.dimension_methods.keys() if dim != "investment_advice"]
|
||||
|
||||
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:
|
||||
# 如果数据库中没有结果,生成新的分析
|
||||
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:
|
||||
all_results[dimension] = result.ai_response
|
||||
|
||||
|
@ -1408,12 +1483,12 @@ class FundamentalAnalyzer:
|
|||
{json.dumps(all_results, ensure_ascii=False, indent=2)}"""
|
||||
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)
|
||||
# 保存到数据库
|
||||
success = save_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
dimension="investment_advice",
|
||||
|
@ -1433,6 +1508,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"生成投资建议失败: {str(e)}")
|
||||
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:
|
||||
"""从投资建议中提取建议类型并更新数据库
|
||||
|
@ -1446,6 +1524,7 @@ class FundamentalAnalyzer:
|
|||
str: 提取的投资建议类型(短期、中期、长期、不建议)或 None
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
valid_types = ["短期", "中期", "长期", "不建议"]
|
||||
max_attempts = 3
|
||||
|
||||
|
@ -1453,10 +1532,10 @@ class FundamentalAnalyzer:
|
|||
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:
|
||||
update_analysis_result(
|
||||
self.db,
|
||||
db_session,
|
||||
stock_code=stock_code,
|
||||
dimension="investment_advice",
|
||||
ai_response=result.ai_response,
|
||||
|
@ -1475,6 +1554,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"提取投资建议类型失败: {str(e)}")
|
||||
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]:
|
||||
"""尝试多次从投资建议中提取建议类型
|
||||
|
@ -1592,6 +1674,7 @@ class FundamentalAnalyzer:
|
|||
Optional[str]: 生成的PDF文件路径,如果失败则返回None
|
||||
"""
|
||||
try:
|
||||
db_session = next(get_db())
|
||||
# 检查是否已存在PDF文件
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'reports')
|
||||
os.makedirs(reports_dir, exist_ok=True)
|
||||
|
@ -1627,7 +1710,7 @@ class FundamentalAnalyzer:
|
|||
# 收集所有可用的分析结果
|
||||
content_dict = {}
|
||||
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:
|
||||
content_dict[dimension_names[dimension]] = result.ai_response
|
||||
|
||||
|
@ -1707,6 +1790,9 @@ class FundamentalAnalyzer:
|
|||
except Exception as e:
|
||||
logger.error(f"生成PDF报告失败: {str(e)}")
|
||||
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:
|
||||
"""检查股票是否已被锁定
|
||||
|
|
|
@ -29,6 +29,7 @@ engine = create_engine(DATABASE_URL)
|
|||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class GpFundamentalAnalysis(Base):
|
||||
"""基本面分析结果表"""
|
||||
__tablename__ = "fundamental_analysis"
|
||||
|
@ -46,10 +47,12 @@ class GpFundamentalAnalysis(Base):
|
|||
def __repr__(self):
|
||||
return f"<GpFundamentalAnalysis(stock_code={self.stock_code}, dimension={self.dimension})>"
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
|
@ -58,6 +61,7 @@ def get_db():
|
|||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# 定义基本面分析的维度
|
||||
ANALYSIS_DIMENSIONS = {
|
||||
"company_profile": "公司简介",
|
||||
|
@ -70,6 +74,7 @@ ANALYSIS_DIMENSIONS = {
|
|||
"investment_suggestion": "投资建议"
|
||||
}
|
||||
|
||||
|
||||
def save_analysis_result(
|
||||
db: Session,
|
||||
stock_code: str,
|
||||
|
@ -133,6 +138,7 @@ def save_analysis_result(
|
|||
db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def get_analysis_result(db: Session, stock_code: str, dimension: str):
|
||||
"""获取特定股票特定维度的分析结果"""
|
||||
try:
|
||||
|
@ -145,6 +151,7 @@ def get_analysis_result(db: Session, stock_code: str, dimension: str):
|
|||
logger.error(f"获取分析结果失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def update_analysis_result(
|
||||
db: Session,
|
||||
stock_code: str,
|
||||
|
|
|
@ -11,7 +11,7 @@ XUEQIU_HEADERS = {
|
|||
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'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',
|
||||
'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
|
@ -79,9 +79,8 @@ MODEL_CONFIGS = {
|
|||
"base_url": "http://192.168.16.174:1234/v1/",
|
||||
"api_key": "none",
|
||||
"models": {
|
||||
"glm-z1": "glm-z1-rumination-32b-0414",
|
||||
"glm-4": "glm-4-32b-0414-abliterated",
|
||||
"ds_v1": "mlx-community/DeepSeek-R1-4bit",
|
||||
"qwen3": "qwen3-235b-a22b",
|
||||
}
|
||||
},
|
||||
# 天链-千问
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
# coding:utf-8
|
||||
# 使用说明:先从通达信里面,选择板块,然后右上角的选项->数据导出,然后把数据复制到C:/Users/Administrator/Desktop/temp/概念板块.csv里面。导入即可,导入之后处理一下,将000233这种变成SZ000233,excel公式是:=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
|
||||
from sqlalchemy import create_engine
|
||||
|
@ -40,6 +41,9 @@ def import_concept_sectors(file_path, db_url, table_name):
|
|||
|
||||
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'
|
||||
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")
|
|
@ -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;
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
# coding:utf-8
|
||||
|
||||
import requests
|
||||
|
@ -6,140 +7,104 @@ from sqlalchemy import create_engine, text
|
|||
from datetime import datetime
|
||||
from tqdm import tqdm
|
||||
from config import XUEQIU_HEADERS
|
||||
import gc
|
||||
import time
|
||||
|
||||
class StockDailyDataCollector:
|
||||
"""股票日线数据采集器类"""
|
||||
|
||||
def __init__(self, db_url):
|
||||
"""
|
||||
初始化采集器
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
db_url : str
|
||||
数据库连接URL
|
||||
"""
|
||||
self.engine = create_engine(db_url)
|
||||
self.engine = create_engine(
|
||||
db_url,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_recycle=3600
|
||||
)
|
||||
self.headers = XUEQIU_HEADERS
|
||||
|
||||
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)
|
||||
return df['gp_code'].tolist()
|
||||
|
||||
def fetch_daily_stock_data(self, symbol, begin):
|
||||
"""
|
||||
获取股票日线数据
|
||||
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
"""
|
||||
保存日线数据到数据库
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
data : dict
|
||||
API返回的数据
|
||||
symbol : str
|
||||
股票代码
|
||||
"""
|
||||
def transform_data(self, data, symbol):
|
||||
try:
|
||||
items = data['data']['item']
|
||||
columns = data['data']['column']
|
||||
except KeyError as e:
|
||||
print(f"KeyError for {symbol}: {e}")
|
||||
return
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(items, columns=columns)
|
||||
df['symbol'] = symbol
|
||||
|
||||
# 数据库中有的字段
|
||||
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]
|
||||
df = df[existing_columns]
|
||||
|
||||
# 数据类型转换
|
||||
if 'timestamp' in df.columns:
|
||||
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):
|
||||
"""
|
||||
获取指定日期或当天的日线数据并保存到数据库
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
date : str, optional
|
||||
日期字符串,格式为'YYYY-MM-DD',如果为None则获取当天数据
|
||||
"""
|
||||
if date is None:
|
||||
# 如果没有指定日期,使用当天日期
|
||||
start_date = datetime.now()
|
||||
date_str = start_date.strftime('%Y-%m-%d')
|
||||
else:
|
||||
start_date = datetime.strptime(date, '%Y-%m-%d')
|
||||
date_str = date
|
||||
|
||||
# 在插入数据之前执行删除操作
|
||||
delete_query = text(f"DELETE FROM gp_day_data WHERE `timestamp` LIKE :date_str")
|
||||
with self.engine.connect() as conn:
|
||||
delete_query = text("DELETE FROM gp_day_data WHERE `timestamp` LIKE :date_str")
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(delete_query, {"date_str": f"{date_str}%"})
|
||||
|
||||
# 获取所有股票代码
|
||||
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)
|
||||
|
||||
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)
|
||||
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}.")
|
||||
|
||||
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.fetch_data_for_date(date)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例调用
|
||||
db_url = 'mysql+pymysql://root:Chlry#$.8@192.168.18.199:3306/db_gp_cj'
|
||||
|
||||
# 方法1:使用快捷函数获取当天数据
|
||||
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') # 获取指定日期数据
|
|
@ -24,13 +24,13 @@ class StockMinuteDataCollector:
|
|||
|
||||
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)
|
||||
return df['gp_code'].tolist()
|
||||
|
||||
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:
|
||||
conn.execute(query, {'market_cap': market_cap, 'symbol': symbol})
|
||||
|
||||
|
|
|
@ -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'
|
File diff suppressed because one or more lines are too long
|
@ -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;
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,提供响应式设计,支持在各种设备上使用。
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
股票估值分析模块
|
||||
包含PE、PB等估值指标的历史分位数分析
|
||||
"""
|
||||
|
||||
from .pe_pb_analysis import *
|
|
@ -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())
|
|
@ -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)
|
|
@ -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()
|
|
@ -0,0 +1,486 @@
|
|||
"""
|
||||
行业估值分析模块
|
||||
|
||||
提供行业历史PE、PB、PS分位数分析功能以及行业拥挤度指标,包括:
|
||||
1. 行业历史PE、PB、PS数据获取
|
||||
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: 估值指标(pe、pb或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: 估值指标,pe、pb或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: 估值指标(pe、pb或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}"}
|
|
@ -0,0 +1,614 @@
|
|||
"""
|
||||
股票PE/PB估值分析模块
|
||||
|
||||
提供股票历史PE、PB分位数分析功能,包括:
|
||||
1. 历史PE、PB数据获取
|
||||
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:
|
||||
"""
|
||||
获取股票的历史PE、PB数据
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
start_date: 开始日期,默认为2018-01-01
|
||||
|
||||
Returns:
|
||||
包含历史PE、PB和价格数据的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)
|
Loading…
Reference in New Issue