Upload 32 files
Browse files- .env +35 -0
- .gitattributes +1 -0
- Dockerfile +32 -8
- auth_middleware.py +79 -0
- capital_flow_analyzer.py +588 -0
- database.py +102 -0
- fundamental_analyzer.py +157 -0
- index_industry_analyzer.py +275 -0
- industry_analyzer.py +533 -0
- industry_api_endpoints.py +9 -0
- requirements.txt +32 -0
- risk_monitor.py +375 -0
- scenario_predictor.py +202 -0
- start.sh +271 -0
- static/favicon.ico +3 -0
- static/swagger.json +573 -0
- stock_analyzer.py +2131 -0
- stock_qa.py +125 -0
- templates/capital_flow.html +775 -0
- templates/dashboard.html +604 -0
- templates/error.html +30 -0
- templates/fundamental.html +405 -0
- templates/index.html +337 -0
- templates/industry_analysis.html +1135 -0
- templates/layout.html +495 -0
- templates/market_scan.html +591 -0
- templates/portfolio.html +605 -0
- templates/qa.html +453 -0
- templates/risk_monitor.html +1041 -0
- templates/scenario_predict.html +388 -0
- templates/stock_detail.html +1200 -0
- us_stock_service.py +67 -0
- web_server.py +1538 -0
.env
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API 提供商 (openai SDK)
|
| 2 |
+
API_PROVIDER=openai # Gemini 未适配,暂时只支持openai SDK,new-api解决方案
|
| 3 |
+
|
| 4 |
+
# OpenAI API 配置
|
| 5 |
+
#OPENAI_API_URL=https://***/v1
|
| 6 |
+
#OPENAI_API_KEY=your_openai_api_key
|
| 7 |
+
OPENAI_API_MODEL=comic-c
|
| 8 |
+
NEWS_MODEL=gpt-4o
|
| 9 |
+
#SERP_API_KEY=your_serp_api_key
|
| 10 |
+
|
| 11 |
+
# Gemini API 配置
|
| 12 |
+
# GEMINI_API_URL=https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent
|
| 13 |
+
# GEMINI_API_KEY=your_gemini_api_key
|
| 14 |
+
# GEMINI_API_MODEL=gemini-pro
|
| 15 |
+
|
| 16 |
+
# 安全配置
|
| 17 |
+
# API_KEY=your_api_key_for_protected_endpoints
|
| 18 |
+
# HMAC_SECRET=your_hmac_secret_key_for_webhook_verification
|
| 19 |
+
# ALLOWED_ORIGINS=http://localhost:8888,https://your-domain.com
|
| 20 |
+
|
| 21 |
+
# Redis缓存设置(可选)
|
| 22 |
+
# REDIS_URL=redis://redis:6379 #docker配置
|
| 23 |
+
REDIS_URL=redis://localhost:6379
|
| 24 |
+
USE_REDIS_CACHE=False
|
| 25 |
+
|
| 26 |
+
# 数据库设置(可选)
|
| 27 |
+
# DATABASE_URL=sqlite:///app/data/stock_analyzer.db #docker配置
|
| 28 |
+
DATABASE_URL=sqlite:///data/stock_analyzer.db
|
| 29 |
+
USE_DATABASE=False
|
| 30 |
+
|
| 31 |
+
# 日志配置
|
| 32 |
+
LOG_LEVEL=INFO
|
| 33 |
+
LOG_FILE=logs/stock_analyzer.log
|
| 34 |
+
|
| 35 |
+
# API_KEY=UZXJfw3YNX80DLfN
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
static/favicon.ico filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -1,8 +1,32 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
RUN mkdir /app/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用Python 3.11基础镜像(因为你的依赖包兼容性更好)
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# 设置工作目录
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 创建数据和日志目录
|
| 8 |
+
RUN mkdir -p /app/data /app/logs
|
| 9 |
+
|
| 10 |
+
# 设置环境变量
|
| 11 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 12 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 13 |
+
|
| 14 |
+
# 安装系统依赖
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
build-essential \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# 复制requirements.txt
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
|
| 22 |
+
# 安装Python依赖
|
| 23 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# 复制应用代码
|
| 26 |
+
COPY . .
|
| 27 |
+
|
| 28 |
+
# 暴露端口(假设Flask应用运行在5000端口)
|
| 29 |
+
EXPOSE 8888
|
| 30 |
+
|
| 31 |
+
# 使用gunicorn启动应用
|
| 32 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:8888", "--workers", "4", "web_server:app"]
|
auth_middleware.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import wraps
|
| 2 |
+
from flask import request, jsonify
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import hashlib
|
| 6 |
+
import hmac
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_api_key():
|
| 10 |
+
return os.getenv('API_KEY', 'UZXJfw3YNX80DLfN')
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def require_api_key(f):
|
| 14 |
+
"""需要API密钥验证的装饰器"""
|
| 15 |
+
@wraps(f)
|
| 16 |
+
def decorated_function(*args, **kwargs):
|
| 17 |
+
api_key = request.headers.get('X-API-Key')
|
| 18 |
+
if not api_key:
|
| 19 |
+
return jsonify({'error': '缺少API密钥'}), 401
|
| 20 |
+
|
| 21 |
+
if api_key != get_api_key():
|
| 22 |
+
return jsonify({'error': '无效的API密钥'}), 403
|
| 23 |
+
|
| 24 |
+
return f(*args, **kwargs)
|
| 25 |
+
return decorated_function
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def generate_hmac_signature(data, secret_key=None):
|
| 29 |
+
if secret_key is None:
|
| 30 |
+
secret_key = os.getenv('HMAC_SECRET', 'default_hmac_secret_for_development')
|
| 31 |
+
|
| 32 |
+
if isinstance(data, dict):
|
| 33 |
+
# 对字典进行排序,确保相同的数据产生相同的签名
|
| 34 |
+
data = '&'.join(f"{k}={v}" for k, v in sorted(data.items()))
|
| 35 |
+
|
| 36 |
+
# 使用HMAC-SHA256生成签名
|
| 37 |
+
signature = hmac.new(
|
| 38 |
+
secret_key.encode(),
|
| 39 |
+
data.encode(),
|
| 40 |
+
hashlib.sha256
|
| 41 |
+
).hexdigest()
|
| 42 |
+
|
| 43 |
+
return signature
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def verify_hmac_signature(request_signature, data, secret_key=None):
|
| 47 |
+
expected_signature = generate_hmac_signature(data, secret_key)
|
| 48 |
+
return hmac.compare_digest(request_signature, expected_signature)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def require_hmac_auth(f):
|
| 52 |
+
"""需要HMAC认证的装饰器"""
|
| 53 |
+
@wraps(f)
|
| 54 |
+
def decorated_function(*args, **kwargs):
|
| 55 |
+
request_signature = request.headers.get('X-HMAC-Signature')
|
| 56 |
+
if not request_signature:
|
| 57 |
+
return jsonify({'error': '缺少HMAC签名'}), 401
|
| 58 |
+
|
| 59 |
+
# 获取请求数据
|
| 60 |
+
data = request.get_json(silent=True) or {}
|
| 61 |
+
|
| 62 |
+
# 添加时间戳防止重放攻击
|
| 63 |
+
timestamp = request.headers.get('X-Timestamp')
|
| 64 |
+
if not timestamp:
|
| 65 |
+
return jsonify({'error': '缺少时间戳'}), 401
|
| 66 |
+
|
| 67 |
+
# 验证时间戳有效性(有效期5分钟)
|
| 68 |
+
current_time = int(time.time())
|
| 69 |
+
if abs(current_time - int(timestamp)) > 300:
|
| 70 |
+
return jsonify({'error': '时间戳已过期'}), 401
|
| 71 |
+
|
| 72 |
+
# 将时间戳加入验证数据
|
| 73 |
+
verification_data = {**data, 'timestamp': timestamp}
|
| 74 |
+
|
| 75 |
+
# 验证签名
|
| 76 |
+
if not verify_hmac_signature(request_signature, verification_data):
|
| 77 |
+
return jsonify({'error': '签名无效'}), 403
|
| 78 |
+
return f(*args, **kwargs)
|
| 79 |
+
return decorated_function
|
capital_flow_analyzer.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# capital_flow_analyzer.py
|
| 2 |
+
import logging
|
| 3 |
+
import traceback
|
| 4 |
+
import akshare as ak
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import numpy as np
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class CapitalFlowAnalyzer:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.data_cache = {}
|
| 13 |
+
|
| 14 |
+
# 设置日志记录
|
| 15 |
+
logging.basicConfig(level=logging.INFO,
|
| 16 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
| 17 |
+
self.logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
def get_concept_fund_flow(self, period="10日排行"):
|
| 20 |
+
"""获取概念/行业资金流向数据"""
|
| 21 |
+
try:
|
| 22 |
+
self.logger.info(f"Getting concept fund flow for period: {period}")
|
| 23 |
+
|
| 24 |
+
# 检查缓存
|
| 25 |
+
cache_key = f"concept_fund_flow_{period}"
|
| 26 |
+
if cache_key in self.data_cache:
|
| 27 |
+
cache_time, cached_data = self.data_cache[cache_key]
|
| 28 |
+
# 如果在最近一小时内有缓存数据,则返回缓存数据
|
| 29 |
+
if (datetime.now() - cache_time).total_seconds() < 3600:
|
| 30 |
+
return cached_data
|
| 31 |
+
|
| 32 |
+
# 从akshare获取数据
|
| 33 |
+
concept_data = ak.stock_fund_flow_concept(symbol=period)
|
| 34 |
+
|
| 35 |
+
# 处理数据
|
| 36 |
+
result = []
|
| 37 |
+
for _, row in concept_data.iterrows():
|
| 38 |
+
try:
|
| 39 |
+
# 列名可能有所不同,所以我们使用灵活的方法
|
| 40 |
+
item = {
|
| 41 |
+
"rank": int(row.get("序号", 0)),
|
| 42 |
+
"sector": row.get("行业", ""),
|
| 43 |
+
"company_count": int(row.get("公司家数", 0)),
|
| 44 |
+
"sector_index": float(row.get("行业指数", 0)),
|
| 45 |
+
"change_percent": self._parse_percent(row.get("阶段涨跌幅", "0%")),
|
| 46 |
+
"inflow": float(row.get("流入资金", 0)),
|
| 47 |
+
"outflow": float(row.get("流出资金", 0)),
|
| 48 |
+
"net_flow": float(row.get("净额", 0))
|
| 49 |
+
}
|
| 50 |
+
result.append(item)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
# self.logger.warning(f"Error processing row in concept fund flow: {str(e)}")
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
# 缓存结果
|
| 56 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 57 |
+
|
| 58 |
+
return result
|
| 59 |
+
except Exception as e:
|
| 60 |
+
self.logger.error(f"Error getting concept fund flow: {str(e)}")
|
| 61 |
+
self.logger.error(traceback.format_exc())
|
| 62 |
+
# 如果API调用失败则返回模拟数据
|
| 63 |
+
return self._generate_mock_concept_fund_flow(period)
|
| 64 |
+
|
| 65 |
+
def get_individual_fund_flow_rank(self, period="10日"):
|
| 66 |
+
"""获取个股资金流向排名"""
|
| 67 |
+
try:
|
| 68 |
+
self.logger.info(f"Getting individual fund flow ranking for period: {period}")
|
| 69 |
+
|
| 70 |
+
# 检查缓存
|
| 71 |
+
cache_key = f"individual_fund_flow_rank_{period}"
|
| 72 |
+
if cache_key in self.data_cache:
|
| 73 |
+
cache_time, cached_data = self.data_cache[cache_key]
|
| 74 |
+
# 如果在最近一小时内有缓存数据,则返回缓存数据
|
| 75 |
+
if (datetime.now() - cache_time).total_seconds() < 3600:
|
| 76 |
+
return cached_data
|
| 77 |
+
|
| 78 |
+
# 从akshare获取数据
|
| 79 |
+
stock_data = ak.stock_individual_fund_flow_rank(indicator=period)
|
| 80 |
+
|
| 81 |
+
# 处理数据
|
| 82 |
+
result = []
|
| 83 |
+
for _, row in stock_data.iterrows():
|
| 84 |
+
try:
|
| 85 |
+
# 根据不同时间段设置列名前缀
|
| 86 |
+
period_prefix = "" if period == "今日" else f"{period}"
|
| 87 |
+
|
| 88 |
+
item = {
|
| 89 |
+
"rank": int(row.get("序号", 0)),
|
| 90 |
+
"code": row.get("代码", ""),
|
| 91 |
+
"name": row.get("名称", ""),
|
| 92 |
+
"price": float(row.get("最新价", 0)),
|
| 93 |
+
"change_percent": float(row.get(f"{period_prefix}涨跌幅", 0)),
|
| 94 |
+
"main_net_inflow": float(row.get(f"{period_prefix}主力净流入-净额", 0)),
|
| 95 |
+
"main_net_inflow_percent": float(row.get(f"{period_prefix}主力净流入-净占比", 0)),
|
| 96 |
+
"super_large_net_inflow": float(row.get(f"{period_prefix}超大单净流入-净额", 0)),
|
| 97 |
+
"super_large_net_inflow_percent": float(row.get(f"{period_prefix}超大单净流入-净占比", 0)),
|
| 98 |
+
"large_net_inflow": float(row.get(f"{period_prefix}大单净流入-净额", 0)),
|
| 99 |
+
"large_net_inflow_percent": float(row.get(f"{period_prefix}大单净流入-净占比", 0)),
|
| 100 |
+
"medium_net_inflow": float(row.get(f"{period_prefix}中单净流入-净额", 0)),
|
| 101 |
+
"medium_net_inflow_percent": float(row.get(f"{period_prefix}中单净流入-净占比", 0)),
|
| 102 |
+
"small_net_inflow": float(row.get(f"{period_prefix}小单净流入-净额", 0)),
|
| 103 |
+
"small_net_inflow_percent": float(row.get(f"{period_prefix}小单净流入-净占比", 0))
|
| 104 |
+
}
|
| 105 |
+
result.append(item)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
self.logger.warning(f"Error processing row in individual fund flow rank: {str(e)}")
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
# 缓存结果
|
| 111 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 112 |
+
|
| 113 |
+
return result
|
| 114 |
+
except Exception as e:
|
| 115 |
+
self.logger.error(f"Error getting individual fund flow ranking: {str(e)}")
|
| 116 |
+
self.logger.error(traceback.format_exc())
|
| 117 |
+
# 如果API调用失败则返回模拟数据
|
| 118 |
+
return self._generate_mock_individual_fund_flow_rank(period)
|
| 119 |
+
|
| 120 |
+
def get_individual_fund_flow(self, stock_code, market_type="", re_date="10日"):
|
| 121 |
+
"""获取个股资金流向数据"""
|
| 122 |
+
try:
|
| 123 |
+
self.logger.info(f"Getting fund flow for stock: {stock_code}, market: {market_type}")
|
| 124 |
+
|
| 125 |
+
# 检查缓存
|
| 126 |
+
cache_key = f"individual_fund_flow_{stock_code}_{market_type}"
|
| 127 |
+
if cache_key in self.data_cache:
|
| 128 |
+
cache_time, cached_data = self.data_cache[cache_key]
|
| 129 |
+
# 如果在一小时内有缓存数据,则返回缓存数据
|
| 130 |
+
if (datetime.now() - cache_time).total_seconds() < 3600:
|
| 131 |
+
return cached_data
|
| 132 |
+
|
| 133 |
+
# 如果未提供市场类型,则根据股票代码判断
|
| 134 |
+
if not market_type:
|
| 135 |
+
if stock_code.startswith('6'):
|
| 136 |
+
market_type = "sh"
|
| 137 |
+
elif stock_code.startswith('0') or stock_code.startswith('3'):
|
| 138 |
+
market_type = "sz"
|
| 139 |
+
else:
|
| 140 |
+
market_type = "sh" # Default to Shanghai
|
| 141 |
+
|
| 142 |
+
# 从akshare获取数据
|
| 143 |
+
flow_data = ak.stock_individual_fund_flow(stock=stock_code, market=market_type)
|
| 144 |
+
|
| 145 |
+
# 处理数据
|
| 146 |
+
result = {
|
| 147 |
+
"stock_code": stock_code,
|
| 148 |
+
"data": []
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
for _, row in flow_data.iterrows():
|
| 152 |
+
try:
|
| 153 |
+
item = {
|
| 154 |
+
"date": row.get("日期", ""),
|
| 155 |
+
"price": float(row.get("收盘价", 0)),
|
| 156 |
+
"change_percent": float(row.get("涨跌幅", 0)),
|
| 157 |
+
"main_net_inflow": float(row.get("主力净流入-净额", 0)),
|
| 158 |
+
"main_net_inflow_percent": float(row.get("主力净流入-净占比", 0)),
|
| 159 |
+
"super_large_net_inflow": float(row.get("超大单净流入-净额", 0)),
|
| 160 |
+
"super_large_net_inflow_percent": float(row.get("超大单净流入-净占比", 0)),
|
| 161 |
+
"large_net_inflow": float(row.get("大单净流入-净额", 0)),
|
| 162 |
+
"large_net_inflow_percent": float(row.get("大单净流入-净占比", 0)),
|
| 163 |
+
"medium_net_inflow": float(row.get("中单净流入-净额", 0)),
|
| 164 |
+
"medium_net_inflow_percent": float(row.get("中单净流入-净占比", 0)),
|
| 165 |
+
"small_net_inflow": float(row.get("小单净流入-净额", 0)),
|
| 166 |
+
"small_net_inflow_percent": float(row.get("小单净流入-净占比", 0))
|
| 167 |
+
}
|
| 168 |
+
result["data"].append(item)
|
| 169 |
+
except Exception as e:
|
| 170 |
+
self.logger.warning(f"Error processing row in individual fund flow: {str(e)}")
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
# 计算汇总统计数据
|
| 174 |
+
if result["data"]:
|
| 175 |
+
# 最近数据 (最近10天)
|
| 176 |
+
recent_data = result["data"][:min(10, len(result["data"]))]
|
| 177 |
+
|
| 178 |
+
result["summary"] = {
|
| 179 |
+
"recent_days": len(recent_data),
|
| 180 |
+
"total_main_net_inflow": sum(item["main_net_inflow"] for item in recent_data),
|
| 181 |
+
"avg_main_net_inflow_percent": np.mean(
|
| 182 |
+
[item["main_net_inflow_percent"] for item in recent_data]),
|
| 183 |
+
"positive_days": sum(1 for item in recent_data if item["main_net_inflow"] > 0),
|
| 184 |
+
"negative_days": sum(1 for item in recent_data if item["main_net_inflow"] <= 0)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
# Cache the result
|
| 188 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 189 |
+
|
| 190 |
+
return result
|
| 191 |
+
except Exception as e:
|
| 192 |
+
self.logger.error(f"Error getting individual fund flow: {str(e)}")
|
| 193 |
+
self.logger.error(traceback.format_exc())
|
| 194 |
+
# 如果API调用失败则返回模拟数据
|
| 195 |
+
return self._generate_mock_individual_fund_flow(stock_code, market_type)
|
| 196 |
+
|
| 197 |
+
def get_sector_stocks(self, sector):
|
| 198 |
+
"""获取特定行业的股票"""
|
| 199 |
+
try:
|
| 200 |
+
self.logger.info(f"Getting stocks for sector: {sector}")
|
| 201 |
+
|
| 202 |
+
# 检查缓存
|
| 203 |
+
cache_key = f"sector_stocks_{sector}"
|
| 204 |
+
if cache_key in self.data_cache:
|
| 205 |
+
cache_time, cached_data = self.data_cache[cache_key]
|
| 206 |
+
# 如果在一小时内有缓存数据,则返回缓存数据
|
| 207 |
+
if (datetime.now() - cache_time).total_seconds() < 3600:
|
| 208 |
+
return cached_data
|
| 209 |
+
|
| 210 |
+
# 尝试从akshare获取数据
|
| 211 |
+
try:
|
| 212 |
+
# For industry sectors (using 东方财富 interface)
|
| 213 |
+
stocks = ak.stock_board_industry_cons_em(symbol=sector)
|
| 214 |
+
|
| 215 |
+
# 提取股票列表
|
| 216 |
+
if not stocks.empty and '代码' in stocks.columns:
|
| 217 |
+
result = []
|
| 218 |
+
for _, row in stocks.iterrows():
|
| 219 |
+
try:
|
| 220 |
+
item = {
|
| 221 |
+
"code": row.get("代码", ""),
|
| 222 |
+
"name": row.get("名称", ""),
|
| 223 |
+
"price": float(row.get("最新价", 0)),
|
| 224 |
+
"change_percent": float(row.get("涨跌幅", 0)) if "涨跌幅" in row else 0,
|
| 225 |
+
"main_net_inflow": 0, # We'll get this data separately if needed
|
| 226 |
+
"main_net_inflow_percent": 0 # We'll get this data separately if needed
|
| 227 |
+
}
|
| 228 |
+
result.append(item)
|
| 229 |
+
except Exception as e:
|
| 230 |
+
# self.logger.warning(f"Error processing row in sector stocks: {str(e)}")
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
# 缓存结果
|
| 234 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 235 |
+
return result
|
| 236 |
+
except Exception as e:
|
| 237 |
+
self.logger.warning(f"Failed to get sector stocks from API: {str(e)}")
|
| 238 |
+
# 降级到模拟数据
|
| 239 |
+
|
| 240 |
+
# 如果到达这里,说明无法从API获取数据,返回模拟数据
|
| 241 |
+
result = self._generate_mock_sector_stocks(sector)
|
| 242 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 243 |
+
return result
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
self.logger.error(f"Error getting sector stocks: {str(e)}")
|
| 247 |
+
self.logger.error(traceback.format_exc())
|
| 248 |
+
# 如果API调用失败则返回模拟数据
|
| 249 |
+
return self._generate_mock_sector_stocks(sector)
|
| 250 |
+
|
| 251 |
+
def calculate_capital_flow_score(self, stock_code, market_type=""):
|
| 252 |
+
"""计算股票资金流向评分"""
|
| 253 |
+
try:
|
| 254 |
+
self.logger.info(f"Calculating capital flow score for stock: {stock_code}")
|
| 255 |
+
|
| 256 |
+
# 获取个股资金流向数据
|
| 257 |
+
fund_flow = self.get_individual_fund_flow(stock_code, market_type)
|
| 258 |
+
|
| 259 |
+
if not fund_flow or not fund_flow.get("data") or not fund_flow.get("summary"):
|
| 260 |
+
return {
|
| 261 |
+
"total": 0,
|
| 262 |
+
"main_force": 0,
|
| 263 |
+
"large_order": 0,
|
| 264 |
+
"small_order": 0,
|
| 265 |
+
"details": {}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
# Extract summary statistics
|
| 269 |
+
summary = fund_flow["summary"]
|
| 270 |
+
recent_days = summary["recent_days"]
|
| 271 |
+
total_main_net_inflow = summary["total_main_net_inflow"]
|
| 272 |
+
avg_main_net_inflow_percent = summary["avg_main_net_inflow_percent"]
|
| 273 |
+
positive_days = summary["positive_days"]
|
| 274 |
+
|
| 275 |
+
# Calculate main force score (0-40)
|
| 276 |
+
main_force_score = 0
|
| 277 |
+
|
| 278 |
+
# 基于净流入百分比的评分
|
| 279 |
+
if avg_main_net_inflow_percent > 3:
|
| 280 |
+
main_force_score += 20
|
| 281 |
+
elif avg_main_net_inflow_percent > 1:
|
| 282 |
+
main_force_score += 15
|
| 283 |
+
elif avg_main_net_inflow_percent > 0:
|
| 284 |
+
main_force_score += 10
|
| 285 |
+
|
| 286 |
+
# 基于上涨天数的评分
|
| 287 |
+
positive_ratio = positive_days / recent_days if recent_days > 0 else 0
|
| 288 |
+
if positive_ratio > 0.7:
|
| 289 |
+
main_force_score += 20
|
| 290 |
+
elif positive_ratio > 0.5:
|
| 291 |
+
main_force_score += 15
|
| 292 |
+
elif positive_ratio > 0.3:
|
| 293 |
+
main_force_score += 10
|
| 294 |
+
|
| 295 |
+
# 计算大单评分(0-30分)
|
| 296 |
+
large_order_score = 0
|
| 297 |
+
|
| 298 |
+
# 分析超大单和大单交易
|
| 299 |
+
recent_super_large = [item["super_large_net_inflow"] for item in
|
| 300 |
+
fund_flow["data"][:recent_days]]
|
| 301 |
+
recent_large = [item["large_net_inflow"] for item in fund_flow["data"][:recent_days]]
|
| 302 |
+
|
| 303 |
+
super_large_positive = sum(1 for x in recent_super_large if x > 0)
|
| 304 |
+
large_positive = sum(1 for x in recent_large if x > 0)
|
| 305 |
+
|
| 306 |
+
# 基于超大单的评分
|
| 307 |
+
super_large_ratio = super_large_positive / recent_days if recent_days > 0 else 0
|
| 308 |
+
if super_large_ratio > 0.7:
|
| 309 |
+
large_order_score += 15
|
| 310 |
+
elif super_large_ratio > 0.5:
|
| 311 |
+
large_order_score += 10
|
| 312 |
+
elif super_large_ratio > 0.3:
|
| 313 |
+
large_order_score += 5
|
| 314 |
+
|
| 315 |
+
# 基于大单的评分
|
| 316 |
+
large_ratio = large_positive / recent_days if recent_days > 0 else 0
|
| 317 |
+
if large_ratio > 0.7:
|
| 318 |
+
large_order_score += 15
|
| 319 |
+
elif large_ratio > 0.5:
|
| 320 |
+
large_order_score += 10
|
| 321 |
+
elif large_ratio > 0.3:
|
| 322 |
+
large_order_score += 5
|
| 323 |
+
|
| 324 |
+
# 计算小单评分(0-30分)
|
| 325 |
+
small_order_score = 0
|
| 326 |
+
|
| 327 |
+
# 分析中单和小单交易
|
| 328 |
+
recent_medium = [item["medium_net_inflow"] for item in fund_flow["data"][:recent_days]]
|
| 329 |
+
recent_small = [item["small_net_inflow"] for item in fund_flow["data"][:recent_days]]
|
| 330 |
+
|
| 331 |
+
medium_positive = sum(1 for x in recent_medium if x > 0)
|
| 332 |
+
small_positive = sum(1 for x in recent_small if x > 0)
|
| 333 |
+
|
| 334 |
+
# 基于中单的评分
|
| 335 |
+
medium_ratio = medium_positive / recent_days if recent_days > 0 else 0
|
| 336 |
+
if medium_ratio > 0.7:
|
| 337 |
+
small_order_score += 15
|
| 338 |
+
elif medium_ratio > 0.5:
|
| 339 |
+
small_order_score += 10
|
| 340 |
+
elif medium_ratio > 0.3:
|
| 341 |
+
small_order_score += 5
|
| 342 |
+
|
| 343 |
+
# 基于小单的评分
|
| 344 |
+
small_ratio = small_positive / recent_days if recent_days > 0 else 0
|
| 345 |
+
if small_ratio > 0.7:
|
| 346 |
+
small_order_score += 15
|
| 347 |
+
elif small_ratio > 0.5:
|
| 348 |
+
small_order_score += 10
|
| 349 |
+
elif small_ratio > 0.3:
|
| 350 |
+
small_order_score += 5
|
| 351 |
+
|
| 352 |
+
# 计算总评分
|
| 353 |
+
total_score = main_force_score + large_order_score + small_order_score
|
| 354 |
+
|
| 355 |
+
return {
|
| 356 |
+
"total": total_score,
|
| 357 |
+
"main_force": main_force_score,
|
| 358 |
+
"large_order": large_order_score,
|
| 359 |
+
"small_order": small_order_score,
|
| 360 |
+
"details": fund_flow
|
| 361 |
+
}
|
| 362 |
+
except Exception as e:
|
| 363 |
+
self.logger.error(f"Error calculating capital flow score: {str(e)}")
|
| 364 |
+
self.logger.error(traceback.format_exc())
|
| 365 |
+
return {
|
| 366 |
+
"total": 0,
|
| 367 |
+
"main_force": 0,
|
| 368 |
+
"large_order": 0,
|
| 369 |
+
"small_order": 0,
|
| 370 |
+
"details": {},
|
| 371 |
+
"error": str(e)
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
def _parse_percent(self, percent_str):
|
| 375 |
+
"""将百分比字符串转换为浮点数"""
|
| 376 |
+
try:
|
| 377 |
+
if isinstance(percent_str, str) and '%' in percent_str:
|
| 378 |
+
return float(percent_str.replace('%', ''))
|
| 379 |
+
return float(percent_str)
|
| 380 |
+
except (ValueError, TypeError):
|
| 381 |
+
return 0.0
|
| 382 |
+
|
| 383 |
+
def _generate_mock_concept_fund_flow(self, period):
|
| 384 |
+
"""生成模拟概念资金流向数据"""
|
| 385 |
+
# self.logger.warning(f"Generating mock concept fund flow data for period: {period}")
|
| 386 |
+
|
| 387 |
+
sectors = [
|
| 388 |
+
"新能源", "医药", "半导体", "芯片", "人工智能", "大数据", "云计算", "5G",
|
| 389 |
+
"汽车", "消费", "金融", "互联网", "游戏", "农业", "化工", "建筑", "军工",
|
| 390 |
+
"钢铁", "有色金属", "煤炭", "石油"
|
| 391 |
+
]
|
| 392 |
+
|
| 393 |
+
result = []
|
| 394 |
+
for i, sector in enumerate(sectors):
|
| 395 |
+
# 随机数据 - 前半部分为正,后半部分为负
|
| 396 |
+
is_positive = i < len(sectors) // 2
|
| 397 |
+
|
| 398 |
+
inflow = round(np.random.uniform(10, 50), 2) if is_positive else round(
|
| 399 |
+
np.random.uniform(5, 20), 2)
|
| 400 |
+
outflow = round(np.random.uniform(5, 20), 2) if is_positive else round(
|
| 401 |
+
np.random.uniform(10, 50), 2)
|
| 402 |
+
net_flow = round(inflow - outflow, 2)
|
| 403 |
+
|
| 404 |
+
change_percent = round(np.random.uniform(0, 5), 2) if is_positive else round(
|
| 405 |
+
np.random.uniform(-5, 0), 2)
|
| 406 |
+
|
| 407 |
+
item = {
|
| 408 |
+
"rank": i + 1,
|
| 409 |
+
"sector": sector,
|
| 410 |
+
"company_count": np.random.randint(10, 100),
|
| 411 |
+
"sector_index": round(np.random.uniform(1000, 5000), 2),
|
| 412 |
+
"change_percent": change_percent,
|
| 413 |
+
"inflow": inflow,
|
| 414 |
+
"outflow": outflow,
|
| 415 |
+
"net_flow": net_flow
|
| 416 |
+
}
|
| 417 |
+
result.append(item)
|
| 418 |
+
|
| 419 |
+
# 按净流入降序排序
|
| 420 |
+
return sorted(result, key=lambda x: x["net_flow"], reverse=True)
|
| 421 |
+
|
| 422 |
+
def _generate_mock_individual_fund_flow_rank(self, period):
|
| 423 |
+
"""生成模拟个股资金流向排名数据"""
|
| 424 |
+
# self.logger.warning(f"Generating mock individual fund flow ranking data for period: {period}")
|
| 425 |
+
|
| 426 |
+
# Sample stock data
|
| 427 |
+
stocks = [
|
| 428 |
+
{"code": "600000", "name": "浦发银行"}, {"code": "600036", "name": "招商银行"},
|
| 429 |
+
{"code": "601318", "name": "中国平安"}, {"code": "600519", "name": "贵州茅台"},
|
| 430 |
+
{"code": "000858", "name": "五粮液"}, {"code": "000333", "name": "美的集团"},
|
| 431 |
+
{"code": "600276", "name": "恒瑞医药"}, {"code": "601888", "name": "中国中免"},
|
| 432 |
+
{"code": "600030", "name": "中信证券"}, {"code": "601166", "name": "兴业银行"},
|
| 433 |
+
{"code": "600887", "name": "伊利股份"}, {"code": "601398", "name": "工商银行"},
|
| 434 |
+
{"code": "600028", "name": "中国石化"}, {"code": "601988", "name": "中国银行"},
|
| 435 |
+
{"code": "601857", "name": "中国石油"}, {"code": "600019", "name": "宝钢股份"},
|
| 436 |
+
{"code": "600050", "name": "中国联通"}, {"code": "601328", "name": "交通银行"},
|
| 437 |
+
{"code": "601668", "name": "中国建筑"}, {"code": "601288", "name": "农业银行"}
|
| 438 |
+
]
|
| 439 |
+
|
| 440 |
+
result = []
|
| 441 |
+
for i, stock in enumerate(stocks):
|
| 442 |
+
# 随机数据 - 前半部分为正,后半部分为负
|
| 443 |
+
is_positive = i < len(stocks) // 2
|
| 444 |
+
|
| 445 |
+
main_net_inflow = round(np.random.uniform(1e6, 5e7), 2) if is_positive else round(
|
| 446 |
+
np.random.uniform(-5e7, -1e6), 2)
|
| 447 |
+
main_net_inflow_percent = round(np.random.uniform(1, 10), 2) if is_positive else round(
|
| 448 |
+
np.random.uniform(-10, -1), 2)
|
| 449 |
+
|
| 450 |
+
super_large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
|
| 451 |
+
super_large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
|
| 452 |
+
|
| 453 |
+
large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
|
| 454 |
+
large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
|
| 455 |
+
|
| 456 |
+
medium_net_inflow = round(np.random.uniform(-1e6, 1e6), 2)
|
| 457 |
+
medium_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
|
| 458 |
+
|
| 459 |
+
small_net_inflow = round(np.random.uniform(-1e6, 1e6), 2)
|
| 460 |
+
small_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
|
| 461 |
+
|
| 462 |
+
change_percent = round(np.random.uniform(0, 5), 2) if is_positive else round(np.random.uniform(-5, 0), 2)
|
| 463 |
+
|
| 464 |
+
item = {
|
| 465 |
+
"rank": i + 1,
|
| 466 |
+
"code": stock["code"],
|
| 467 |
+
"name": stock["name"],
|
| 468 |
+
"price": round(np.random.uniform(10, 100), 2),
|
| 469 |
+
"change_percent": change_percent,
|
| 470 |
+
"main_net_inflow": main_net_inflow,
|
| 471 |
+
"main_net_inflow_percent": main_net_inflow_percent,
|
| 472 |
+
"super_large_net_inflow": super_large_net_inflow,
|
| 473 |
+
"super_large_net_inflow_percent": super_large_net_inflow_percent,
|
| 474 |
+
"large_net_inflow": large_net_inflow,
|
| 475 |
+
"large_net_inflow_percent": large_net_inflow_percent,
|
| 476 |
+
"medium_net_inflow": medium_net_inflow,
|
| 477 |
+
"medium_net_inflow_percent": medium_net_inflow_percent,
|
| 478 |
+
"small_net_inflow": small_net_inflow,
|
| 479 |
+
"small_net_inflow_percent": small_net_inflow_percent
|
| 480 |
+
}
|
| 481 |
+
result.append(item)
|
| 482 |
+
|
| 483 |
+
# 按主力净流入降序排序
|
| 484 |
+
return sorted(result, key=lambda x: x["main_net_inflow"], reverse=True)
|
| 485 |
+
|
| 486 |
+
def _generate_mock_individual_fund_flow(self, stock_code, market_type):
|
| 487 |
+
"""生成模拟个股资金流向数据"""
|
| 488 |
+
# self.logger.warning(f"Generating mock individual fund flow data for stock: {stock_code}")
|
| 489 |
+
|
| 490 |
+
# 生成30天的模拟数据
|
| 491 |
+
end_date = datetime.now()
|
| 492 |
+
|
| 493 |
+
result = {
|
| 494 |
+
"stock_code": stock_code,
|
| 495 |
+
"data": []
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
# 创建模拟价格趋势(使用合理的随机游走)
|
| 499 |
+
base_price = np.random.uniform(10, 100)
|
| 500 |
+
current_price = base_price
|
| 501 |
+
|
| 502 |
+
for i in range(30):
|
| 503 |
+
date = (end_date - timedelta(days=i)).strftime('%Y-%m-%d')
|
| 504 |
+
|
| 505 |
+
# 随机价格变化(-2%到+2%)
|
| 506 |
+
change_percent = np.random.uniform(-2, 2)
|
| 507 |
+
price = round(current_price * (1 + change_percent / 100), 2)
|
| 508 |
+
current_price = price
|
| 509 |
+
|
| 510 |
+
# 随机资金流向数据,与价格变化有一定相关性
|
| 511 |
+
is_positive = change_percent > 0
|
| 512 |
+
|
| 513 |
+
main_net_inflow = round(np.random.uniform(1e5, 5e6), 2) if is_positive else round(
|
| 514 |
+
np.random.uniform(-5e6, -1e5), 2)
|
| 515 |
+
main_net_inflow_percent = round(np.random.uniform(1, 5), 2) if is_positive else round(
|
| 516 |
+
np.random.uniform(-5, -1), 2)
|
| 517 |
+
|
| 518 |
+
super_large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
|
| 519 |
+
super_large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
|
| 520 |
+
|
| 521 |
+
large_net_inflow = round(main_net_inflow * np.random.uniform(0.3, 0.5), 2)
|
| 522 |
+
large_net_inflow_percent = round(main_net_inflow_percent * np.random.uniform(0.3, 0.5), 2)
|
| 523 |
+
|
| 524 |
+
medium_net_inflow = round(np.random.uniform(-1e5, 1e5), 2)
|
| 525 |
+
medium_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
|
| 526 |
+
|
| 527 |
+
small_net_inflow = round(np.random.uniform(-1e5, 1e5), 2)
|
| 528 |
+
small_net_inflow_percent = round(np.random.uniform(-2, 2), 2)
|
| 529 |
+
|
| 530 |
+
item = {
|
| 531 |
+
"date": date,
|
| 532 |
+
"price": price,
|
| 533 |
+
"change_percent": round(change_percent, 2),
|
| 534 |
+
"main_net_inflow": main_net_inflow,
|
| 535 |
+
"main_net_inflow_percent": main_net_inflow_percent,
|
| 536 |
+
"super_large_net_inflow": super_large_net_inflow,
|
| 537 |
+
"super_large_net_inflow_percent": super_large_net_inflow_percent,
|
| 538 |
+
"large_net_inflow": large_net_inflow,
|
| 539 |
+
"large_net_inflow_percent": large_net_inflow_percent,
|
| 540 |
+
"medium_net_inflow": medium_net_inflow,
|
| 541 |
+
"medium_net_inflow_percent": medium_net_inflow_percent,
|
| 542 |
+
"small_net_inflow": small_net_inflow,
|
| 543 |
+
"small_net_inflow_percent": small_net_inflow_percent
|
| 544 |
+
}
|
| 545 |
+
result["data"].append(item)
|
| 546 |
+
|
| 547 |
+
# 按日期降序排序(最新的在前)
|
| 548 |
+
result["data"].sort(key=lambda x: x["date"], reverse=True)
|
| 549 |
+
|
| 550 |
+
# 计算汇总统计数据
|
| 551 |
+
recent_data = result["data"][:10]
|
| 552 |
+
|
| 553 |
+
result["summary"] = {
|
| 554 |
+
"recent_days": len(recent_data),
|
| 555 |
+
"total_main_net_inflow": sum(item["main_net_inflow"] for item in recent_data),
|
| 556 |
+
"avg_main_net_inflow_percent": np.mean([item["main_net_inflow_percent"] for item in recent_data]),
|
| 557 |
+
"positive_days": sum(1 for item in recent_data if item["main_net_inflow"] > 0),
|
| 558 |
+
"negative_days": sum(1 for item in recent_data if item["main_net_inflow"] <= 0)
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
return result
|
| 562 |
+
|
| 563 |
+
def _generate_mock_sector_stocks(self, sector):
|
| 564 |
+
"""生成模拟行业股票数据"""
|
| 565 |
+
# self.logger.warning(f"Generating mock sector stocks for: {sector}")
|
| 566 |
+
|
| 567 |
+
# 要生成的股票数量
|
| 568 |
+
num_stocks = np.random.randint(20, 50)
|
| 569 |
+
|
| 570 |
+
result = []
|
| 571 |
+
for i in range(num_stocks):
|
| 572 |
+
prefix = "6" if np.random.random() > 0.5 else "0"
|
| 573 |
+
stock_code = prefix + str(100000 + i).zfill(5)[-5:]
|
| 574 |
+
|
| 575 |
+
change_percent = round(np.random.uniform(-5, 5), 2)
|
| 576 |
+
|
| 577 |
+
item = {
|
| 578 |
+
"code": stock_code,
|
| 579 |
+
"name": f"{sector}股票{i + 1}",
|
| 580 |
+
"price": round(np.random.uniform(10, 100), 2),
|
| 581 |
+
"change_percent": change_percent,
|
| 582 |
+
"main_net_inflow": round(np.random.uniform(-1e6, 1e6), 2),
|
| 583 |
+
"main_net_inflow_percent": round(np.random.uniform(-5, 5), 2)
|
| 584 |
+
}
|
| 585 |
+
result.append(item)
|
| 586 |
+
|
| 587 |
+
# 按主力净流入降序排序
|
| 588 |
+
return sorted(result, key=lambda x: x["main_net_inflow"], reverse=True)
|
database.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Text, JSON
|
| 3 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 4 |
+
from sqlalchemy.orm import sessionmaker
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
# 读取配置
|
| 8 |
+
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///data/stock_analyzer.db')
|
| 9 |
+
USE_DATABASE = os.getenv('USE_DATABASE', 'False').lower() == 'true'
|
| 10 |
+
|
| 11 |
+
# 创建引擎
|
| 12 |
+
engine = create_engine(DATABASE_URL)
|
| 13 |
+
Base = declarative_base()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# 定义模型
|
| 17 |
+
class StockInfo(Base):
|
| 18 |
+
__tablename__ = 'stock_info'
|
| 19 |
+
|
| 20 |
+
id = Column(Integer, primary_key=True)
|
| 21 |
+
stock_code = Column(String(10), nullable=False, index=True)
|
| 22 |
+
stock_name = Column(String(50))
|
| 23 |
+
market_type = Column(String(5))
|
| 24 |
+
industry = Column(String(50))
|
| 25 |
+
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
| 26 |
+
|
| 27 |
+
def to_dict(self):
|
| 28 |
+
return {
|
| 29 |
+
'stock_code': self.stock_code,
|
| 30 |
+
'stock_name': self.stock_name,
|
| 31 |
+
'market_type': self.market_type,
|
| 32 |
+
'industry': self.industry,
|
| 33 |
+
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class AnalysisResult(Base):
|
| 38 |
+
__tablename__ = 'analysis_results'
|
| 39 |
+
|
| 40 |
+
id = Column(Integer, primary_key=True)
|
| 41 |
+
stock_code = Column(String(10), nullable=False, index=True)
|
| 42 |
+
market_type = Column(String(5))
|
| 43 |
+
analysis_date = Column(DateTime, default=datetime.now)
|
| 44 |
+
score = Column(Float)
|
| 45 |
+
recommendation = Column(String(100))
|
| 46 |
+
technical_data = Column(JSON)
|
| 47 |
+
fundamental_data = Column(JSON)
|
| 48 |
+
capital_flow_data = Column(JSON)
|
| 49 |
+
ai_analysis = Column(Text)
|
| 50 |
+
|
| 51 |
+
def to_dict(self):
|
| 52 |
+
return {
|
| 53 |
+
'stock_code': self.stock_code,
|
| 54 |
+
'market_type': self.market_type,
|
| 55 |
+
'analysis_date': self.analysis_date.strftime('%Y-%m-%d %H:%M:%S') if self.analysis_date else None,
|
| 56 |
+
'score': self.score,
|
| 57 |
+
'recommendation': self.recommendation,
|
| 58 |
+
'technical_data': self.technical_data,
|
| 59 |
+
'fundamental_data': self.fundamental_data,
|
| 60 |
+
'capital_flow_data': self.capital_flow_data,
|
| 61 |
+
'ai_analysis': self.ai_analysis
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class Portfolio(Base):
|
| 66 |
+
__tablename__ = 'portfolios'
|
| 67 |
+
|
| 68 |
+
id = Column(Integer, primary_key=True)
|
| 69 |
+
user_id = Column(String(50), nullable=False, index=True)
|
| 70 |
+
name = Column(String(100))
|
| 71 |
+
created_at = Column(DateTime, default=datetime.now)
|
| 72 |
+
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
| 73 |
+
stocks = Column(JSON) # 存储股票列表的JSON
|
| 74 |
+
|
| 75 |
+
def to_dict(self):
|
| 76 |
+
return {
|
| 77 |
+
'id': self.id,
|
| 78 |
+
'user_id': self.user_id,
|
| 79 |
+
'name': self.name,
|
| 80 |
+
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
| 81 |
+
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
| 82 |
+
'stocks': self.stocks
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# 创建会话工厂
|
| 87 |
+
Session = sessionmaker(bind=engine)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# 初始化数据库
|
| 91 |
+
def init_db():
|
| 92 |
+
Base.metadata.create_all(engine)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# 获取数据库会话
|
| 96 |
+
def get_session():
|
| 97 |
+
return Session()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# 如果启用数据库,则初始化
|
| 101 |
+
if USE_DATABASE:
|
| 102 |
+
init_db()
|
fundamental_analyzer.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
开发者:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# fundamental_analyzer.py
|
| 9 |
+
import akshare as ak
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FundamentalAnalyzer:
|
| 15 |
+
def __init__(self):
|
| 16 |
+
"""初始化基础分析类"""
|
| 17 |
+
self.data_cache = {}
|
| 18 |
+
|
| 19 |
+
def get_financial_indicators(self, stock_code):
|
| 20 |
+
"""获取财务指标数据"""
|
| 21 |
+
try:
|
| 22 |
+
# 获取基本财务指标
|
| 23 |
+
financial_data = ak.stock_financial_analysis_indicator(symbol=stock_code,start_year="2022")
|
| 24 |
+
|
| 25 |
+
# 获取最新估值指标
|
| 26 |
+
valuation = ak.stock_value_em(symbol=stock_code)
|
| 27 |
+
|
| 28 |
+
# 整合数据
|
| 29 |
+
indicators = {
|
| 30 |
+
'pe_ttm': float(valuation['PE(TTM)'].iloc[0]),
|
| 31 |
+
'pb': float(valuation['市净率'].iloc[0]),
|
| 32 |
+
'ps_ttm': float(valuation['市销率'].iloc[0]),
|
| 33 |
+
'roe': float(financial_data['加权净资产收益率(%)'].iloc[0]),
|
| 34 |
+
'gross_margin': float(financial_data['销售毛利率(%)'].iloc[0]),
|
| 35 |
+
'net_profit_margin': float(financial_data['总资产净利润率(%)'].iloc[0]),
|
| 36 |
+
'debt_ratio': float(financial_data['资产负债率(%)'].iloc[0])
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return indicators
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print(f"获取财务指标出错: {str(e)}")
|
| 42 |
+
return {}
|
| 43 |
+
|
| 44 |
+
def get_growth_data(self, stock_code):
|
| 45 |
+
"""获取成长性数据"""
|
| 46 |
+
try:
|
| 47 |
+
# 获取历年财务数据
|
| 48 |
+
financial_data = ak.stock_financial_abstract(symbol=stock_code)
|
| 49 |
+
|
| 50 |
+
# 计算各项成长率
|
| 51 |
+
revenue = financial_data['营业收入'].astype(float)
|
| 52 |
+
net_profit = financial_data['净利润'].astype(float)
|
| 53 |
+
|
| 54 |
+
growth = {
|
| 55 |
+
'revenue_growth_3y': self._calculate_cagr(revenue, 3),
|
| 56 |
+
'profit_growth_3y': self._calculate_cagr(net_profit, 3),
|
| 57 |
+
'revenue_growth_5y': self._calculate_cagr(revenue, 5),
|
| 58 |
+
'profit_growth_5y': self._calculate_cagr(net_profit, 5)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return growth
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"获取成长数据出错: {str(e)}")
|
| 64 |
+
return {}
|
| 65 |
+
|
| 66 |
+
def _calculate_cagr(self, series, years):
|
| 67 |
+
"""计算复合年增长率"""
|
| 68 |
+
if len(series) < years:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
latest = series.iloc[0]
|
| 72 |
+
earlier = series.iloc[min(years, len(series) - 1)]
|
| 73 |
+
|
| 74 |
+
if earlier <= 0:
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
return ((latest / earlier) ** (1 / years) - 1) * 100
|
| 78 |
+
|
| 79 |
+
def calculate_fundamental_score(self, stock_code):
|
| 80 |
+
"""计算基本面综合评分"""
|
| 81 |
+
indicators = self.get_financial_indicators(stock_code)
|
| 82 |
+
growth = self.get_growth_data(stock_code)
|
| 83 |
+
|
| 84 |
+
# 估值评分 (30分)
|
| 85 |
+
valuation_score = 0
|
| 86 |
+
if 'pe_ttm' in indicators and indicators['pe_ttm'] > 0:
|
| 87 |
+
pe = indicators['pe_ttm']
|
| 88 |
+
if pe < 15:
|
| 89 |
+
valuation_score += 25
|
| 90 |
+
elif pe < 25:
|
| 91 |
+
valuation_score += 20
|
| 92 |
+
elif pe < 35:
|
| 93 |
+
valuation_score += 15
|
| 94 |
+
elif pe < 50:
|
| 95 |
+
valuation_score += 10
|
| 96 |
+
else:
|
| 97 |
+
valuation_score += 5
|
| 98 |
+
|
| 99 |
+
# 财务健康评分 (40分)
|
| 100 |
+
financial_score = 0
|
| 101 |
+
if 'roe' in indicators:
|
| 102 |
+
roe = indicators['roe']
|
| 103 |
+
if roe > 20:
|
| 104 |
+
financial_score += 15
|
| 105 |
+
elif roe > 15:
|
| 106 |
+
financial_score += 12
|
| 107 |
+
elif roe > 10:
|
| 108 |
+
financial_score += 8
|
| 109 |
+
elif roe > 5:
|
| 110 |
+
financial_score += 4
|
| 111 |
+
|
| 112 |
+
if 'debt_ratio' in indicators:
|
| 113 |
+
debt_ratio = indicators['debt_ratio']
|
| 114 |
+
if debt_ratio < 30:
|
| 115 |
+
financial_score += 15
|
| 116 |
+
elif debt_ratio < 50:
|
| 117 |
+
financial_score += 10
|
| 118 |
+
elif debt_ratio < 70:
|
| 119 |
+
financial_score += 5
|
| 120 |
+
|
| 121 |
+
# 成长性评分 (30分)
|
| 122 |
+
growth_score = 0
|
| 123 |
+
if 'revenue_growth_3y' in growth and growth['revenue_growth_3y']:
|
| 124 |
+
rev_growth = growth['revenue_growth_3y']
|
| 125 |
+
if rev_growth > 30:
|
| 126 |
+
growth_score += 15
|
| 127 |
+
elif rev_growth > 20:
|
| 128 |
+
growth_score += 12
|
| 129 |
+
elif rev_growth > 10:
|
| 130 |
+
growth_score += 8
|
| 131 |
+
elif rev_growth > 0:
|
| 132 |
+
growth_score += 4
|
| 133 |
+
|
| 134 |
+
if 'profit_growth_3y' in growth and growth['profit_growth_3y']:
|
| 135 |
+
profit_growth = growth['profit_growth_3y']
|
| 136 |
+
if profit_growth > 30:
|
| 137 |
+
growth_score += 15
|
| 138 |
+
elif profit_growth > 20:
|
| 139 |
+
growth_score += 12
|
| 140 |
+
elif profit_growth > 10:
|
| 141 |
+
growth_score += 8
|
| 142 |
+
elif profit_growth > 0:
|
| 143 |
+
growth_score += 4
|
| 144 |
+
|
| 145 |
+
# 计算总分
|
| 146 |
+
total_score = valuation_score + financial_score + growth_score
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
'total': total_score,
|
| 150 |
+
'valuation': valuation_score,
|
| 151 |
+
'financial_health': financial_score,
|
| 152 |
+
'growth': growth_score,
|
| 153 |
+
'details': {
|
| 154 |
+
'indicators': indicators,
|
| 155 |
+
'growth': growth
|
| 156 |
+
}
|
| 157 |
+
}
|
index_industry_analyzer.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# index_industry_analyzer.py
|
| 2 |
+
import akshare as ak
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
import threading
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class IndexIndustryAnalyzer:
|
| 9 |
+
def __init__(self, analyzer):
|
| 10 |
+
self.analyzer = analyzer
|
| 11 |
+
self.data_cache = {}
|
| 12 |
+
|
| 13 |
+
def analyze_index(self, index_code, limit=30):
|
| 14 |
+
"""分析指数整体情况"""
|
| 15 |
+
try:
|
| 16 |
+
cache_key = f"index_{index_code}"
|
| 17 |
+
if cache_key in self.data_cache:
|
| 18 |
+
cache_time, cached_result = self.data_cache[cache_key]
|
| 19 |
+
# 如果缓存时间在1小时内,直接返回
|
| 20 |
+
if (pd.Timestamp.now() - cache_time).total_seconds() < 3600:
|
| 21 |
+
return cached_result
|
| 22 |
+
|
| 23 |
+
# 获取指数成分股
|
| 24 |
+
if index_code == '000300':
|
| 25 |
+
# 沪深300成分股
|
| 26 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000300")
|
| 27 |
+
index_name = "沪深300"
|
| 28 |
+
elif index_code == '000905':
|
| 29 |
+
# 中证500成分股
|
| 30 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000905")
|
| 31 |
+
index_name = "中证500"
|
| 32 |
+
elif index_code == '000852':
|
| 33 |
+
# 中证1000成分股
|
| 34 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000852")
|
| 35 |
+
index_name = "中证1000"
|
| 36 |
+
elif index_code == '000001':
|
| 37 |
+
# 上证指数
|
| 38 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000001")
|
| 39 |
+
index_name = "上证指数"
|
| 40 |
+
else:
|
| 41 |
+
return {"error": "不支持的指数代码"}
|
| 42 |
+
|
| 43 |
+
# 提取股票代码列表和权重
|
| 44 |
+
stock_list = []
|
| 45 |
+
if '成分券代码' in stocks.columns:
|
| 46 |
+
stock_list = stocks['成分券代码'].tolist()
|
| 47 |
+
weights = stocks['权重(%)'].tolist() if '权重(%)' in stocks.columns else [1] * len(stock_list)
|
| 48 |
+
else:
|
| 49 |
+
return {"error": "获取指数成分股失败"}
|
| 50 |
+
|
| 51 |
+
# 限制分析的股票数量以提高性能
|
| 52 |
+
if limit and len(stock_list) > limit:
|
| 53 |
+
# 按权重排序,取前limit只权重最大的股票
|
| 54 |
+
stock_weights = list(zip(stock_list, weights))
|
| 55 |
+
stock_weights.sort(key=lambda x: x[1], reverse=True)
|
| 56 |
+
stock_list = [s[0] for s in stock_weights[:limit]]
|
| 57 |
+
weights = [s[1] for s in stock_weights[:limit]]
|
| 58 |
+
|
| 59 |
+
# 多线程分析股票
|
| 60 |
+
results = []
|
| 61 |
+
threads = []
|
| 62 |
+
results_lock = threading.Lock()
|
| 63 |
+
|
| 64 |
+
def analyze_stock(stock_code, weight):
|
| 65 |
+
try:
|
| 66 |
+
# 分析股票
|
| 67 |
+
result = self.analyzer.quick_analyze_stock(stock_code)
|
| 68 |
+
result['weight'] = weight
|
| 69 |
+
|
| 70 |
+
with results_lock:
|
| 71 |
+
results.append(result)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"分析股票 {stock_code} 时出错: {str(e)}")
|
| 74 |
+
|
| 75 |
+
# 创建并启动线程
|
| 76 |
+
for i, stock_code in enumerate(stock_list):
|
| 77 |
+
weight = weights[i] if i < len(weights) else 1
|
| 78 |
+
thread = threading.Thread(target=analyze_stock, args=(stock_code, weight))
|
| 79 |
+
threads.append(thread)
|
| 80 |
+
thread.start()
|
| 81 |
+
|
| 82 |
+
# 等待所有线程完成
|
| 83 |
+
for thread in threads:
|
| 84 |
+
thread.join()
|
| 85 |
+
|
| 86 |
+
# 计算指数整体情况
|
| 87 |
+
total_weight = sum([r.get('weight', 1) for r in results])
|
| 88 |
+
|
| 89 |
+
# 计算加权评分
|
| 90 |
+
index_score = 0
|
| 91 |
+
if total_weight > 0:
|
| 92 |
+
index_score = sum([r.get('score', 0) * r.get('weight', 1) for r in results]) / total_weight
|
| 93 |
+
|
| 94 |
+
# 计算其他指标
|
| 95 |
+
up_count = sum(1 for r in results if r.get('price_change', 0) > 0)
|
| 96 |
+
down_count = sum(1 for r in results if r.get('price_change', 0) < 0)
|
| 97 |
+
flat_count = len(results) - up_count - down_count
|
| 98 |
+
|
| 99 |
+
# 计算涨跌股比例
|
| 100 |
+
up_ratio = up_count / len(results) if len(results) > 0 else 0
|
| 101 |
+
|
| 102 |
+
# 计算加权平均涨跌幅
|
| 103 |
+
weighted_change = 0
|
| 104 |
+
if total_weight > 0:
|
| 105 |
+
weighted_change = sum([r.get('price_change', 0) * r.get('weight', 1) for r in results]) / total_weight
|
| 106 |
+
|
| 107 |
+
# 按评分对股票排序
|
| 108 |
+
results.sort(key=lambda x: x.get('score', 0), reverse=True)
|
| 109 |
+
|
| 110 |
+
# 整理结果
|
| 111 |
+
index_analysis = {
|
| 112 |
+
"index_code": index_code,
|
| 113 |
+
"index_name": index_name,
|
| 114 |
+
"score": round(index_score, 2),
|
| 115 |
+
"stock_count": len(results),
|
| 116 |
+
"up_count": up_count,
|
| 117 |
+
"down_count": down_count,
|
| 118 |
+
"flat_count": flat_count,
|
| 119 |
+
"up_ratio": up_ratio,
|
| 120 |
+
"weighted_change": weighted_change,
|
| 121 |
+
"top_stocks": results[:5] if len(results) >= 5 else results,
|
| 122 |
+
"results": results
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# 缓存结果
|
| 126 |
+
self.data_cache[cache_key] = (pd.Timestamp.now(), index_analysis)
|
| 127 |
+
|
| 128 |
+
return index_analysis
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
print(f"分析指数整体情况时出错: {str(e)}")
|
| 132 |
+
return {"error": f"分析指数时出错: {str(e)}"}
|
| 133 |
+
|
| 134 |
+
def analyze_industry(self, industry, limit=30):
|
| 135 |
+
"""分析行业整体情况"""
|
| 136 |
+
try:
|
| 137 |
+
cache_key = f"industry_{industry}"
|
| 138 |
+
if cache_key in self.data_cache:
|
| 139 |
+
cache_time, cached_result = self.data_cache[cache_key]
|
| 140 |
+
# 如果缓存时间在1小时内,直接返回
|
| 141 |
+
if (pd.Timestamp.now() - cache_time).total_seconds() < 3600:
|
| 142 |
+
return cached_result
|
| 143 |
+
|
| 144 |
+
# 获取行业成分股
|
| 145 |
+
stocks = ak.stock_board_industry_cons_em(symbol=industry)
|
| 146 |
+
|
| 147 |
+
# 提取股票代码列表
|
| 148 |
+
stock_list = stocks['代码'].tolist() if '代码' in stocks.columns else []
|
| 149 |
+
|
| 150 |
+
if not stock_list:
|
| 151 |
+
return {"error": "获取行业成分股失败"}
|
| 152 |
+
|
| 153 |
+
# 限制分析的股票数量以提高性能
|
| 154 |
+
if limit and len(stock_list) > limit:
|
| 155 |
+
stock_list = stock_list[:limit]
|
| 156 |
+
|
| 157 |
+
# 多线程分析股票
|
| 158 |
+
results = []
|
| 159 |
+
threads = []
|
| 160 |
+
results_lock = threading.Lock()
|
| 161 |
+
|
| 162 |
+
def analyze_stock(stock_code):
|
| 163 |
+
try:
|
| 164 |
+
# 分析股票
|
| 165 |
+
result = self.analyzer.quick_analyze_stock(stock_code)
|
| 166 |
+
|
| 167 |
+
with results_lock:
|
| 168 |
+
results.append(result)
|
| 169 |
+
except Exception as e:
|
| 170 |
+
print(f"分析股票 {stock_code} 时出错: {str(e)}")
|
| 171 |
+
|
| 172 |
+
# 创建并启动线程
|
| 173 |
+
for stock_code in stock_list:
|
| 174 |
+
thread = threading.Thread(target=analyze_stock, args=(stock_code,))
|
| 175 |
+
threads.append(thread)
|
| 176 |
+
thread.start()
|
| 177 |
+
|
| 178 |
+
# 等待所有线程完成
|
| 179 |
+
for thread in threads:
|
| 180 |
+
thread.join()
|
| 181 |
+
|
| 182 |
+
# 计算行业整体情况
|
| 183 |
+
if not results:
|
| 184 |
+
return {"error": "分析行业股票失败"}
|
| 185 |
+
|
| 186 |
+
# 计算平均评分
|
| 187 |
+
industry_score = sum([r.get('score', 0) for r in results]) / len(results)
|
| 188 |
+
|
| 189 |
+
# 计算其他指标
|
| 190 |
+
up_count = sum(1 for r in results if r.get('price_change', 0) > 0)
|
| 191 |
+
down_count = sum(1 for r in results if r.get('price_change', 0) < 0)
|
| 192 |
+
flat_count = len(results) - up_count - down_count
|
| 193 |
+
|
| 194 |
+
# 计算涨跌股比例
|
| 195 |
+
up_ratio = up_count / len(results)
|
| 196 |
+
|
| 197 |
+
# 计算平均涨跌幅
|
| 198 |
+
avg_change = sum([r.get('price_change', 0) for r in results]) / len(results)
|
| 199 |
+
|
| 200 |
+
# 按评分对股票排序
|
| 201 |
+
results.sort(key=lambda x: x.get('score', 0), reverse=True)
|
| 202 |
+
|
| 203 |
+
# 整理结果
|
| 204 |
+
industry_analysis = {
|
| 205 |
+
"industry": industry,
|
| 206 |
+
"score": round(industry_score, 2),
|
| 207 |
+
"stock_count": len(results),
|
| 208 |
+
"up_count": up_count,
|
| 209 |
+
"down_count": down_count,
|
| 210 |
+
"flat_count": flat_count,
|
| 211 |
+
"up_ratio": up_ratio,
|
| 212 |
+
"avg_change": avg_change,
|
| 213 |
+
"top_stocks": results[:5] if len(results) >= 5 else results,
|
| 214 |
+
"results": results
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# 缓存结果
|
| 218 |
+
self.data_cache[cache_key] = (pd.Timestamp.now(), industry_analysis)
|
| 219 |
+
|
| 220 |
+
return industry_analysis
|
| 221 |
+
|
| 222 |
+
except Exception as e:
|
| 223 |
+
print(f"分析行业整体情况时出错: {str(e)}")
|
| 224 |
+
return {"error": f"分析行业时出错: {str(e)}"}
|
| 225 |
+
|
| 226 |
+
def compare_industries(self, limit=10):
|
| 227 |
+
"""比较不同行业的表现"""
|
| 228 |
+
try:
|
| 229 |
+
# 获取行业板块数据
|
| 230 |
+
industry_data = ak.stock_board_industry_name_em()
|
| 231 |
+
|
| 232 |
+
# 提取行业名称列表
|
| 233 |
+
industries = industry_data['板块名称'].tolist() if '板块名称' in industry_data.columns else []
|
| 234 |
+
|
| 235 |
+
if not industries:
|
| 236 |
+
return {"error": "获取行业列表失败"}
|
| 237 |
+
|
| 238 |
+
# 限制分析的行业数量
|
| 239 |
+
industries = industries[:limit] if limit else industries
|
| 240 |
+
|
| 241 |
+
# 分析各行业情况
|
| 242 |
+
industry_results = []
|
| 243 |
+
|
| 244 |
+
for industry in industries:
|
| 245 |
+
try:
|
| 246 |
+
# 简化分析,只获取基本指标
|
| 247 |
+
industry_info = ak.stock_board_industry_hist_em(symbol=industry, period="3m")
|
| 248 |
+
|
| 249 |
+
# 计算行业涨跌幅
|
| 250 |
+
if not industry_info.empty:
|
| 251 |
+
latest = industry_info.iloc[0]
|
| 252 |
+
change = latest['涨跌幅'] if '涨跌幅' in latest.index else 0
|
| 253 |
+
|
| 254 |
+
industry_results.append({
|
| 255 |
+
"industry": industry,
|
| 256 |
+
"change": change,
|
| 257 |
+
"volume": latest['成交量'] if '成交量' in latest.index else 0,
|
| 258 |
+
"turnover": latest['成交额'] if '成交额' in latest.index else 0
|
| 259 |
+
})
|
| 260 |
+
except Exception as e:
|
| 261 |
+
print(f"分析行业 {industry} 时出错: {str(e)}")
|
| 262 |
+
|
| 263 |
+
# 按涨跌幅排序
|
| 264 |
+
industry_results.sort(key=lambda x: x.get('change', 0), reverse=True)
|
| 265 |
+
|
| 266 |
+
return {
|
| 267 |
+
"count": len(industry_results),
|
| 268 |
+
"top_industries": industry_results[:5] if len(industry_results) >= 5 else industry_results,
|
| 269 |
+
"bottom_industries": industry_results[-5:] if len(industry_results) >= 5 else [],
|
| 270 |
+
"results": industry_results
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
except Exception as e:
|
| 274 |
+
print(f"比较行业表现时出错: {str(e)}")
|
| 275 |
+
return {"error": f"比较行业表现时出错: {str(e)}"}
|
industry_analyzer.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
开发者:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# industry_analyzer.py
|
| 9 |
+
import logging
|
| 10 |
+
import random
|
| 11 |
+
import akshare as ak
|
| 12 |
+
import pandas as pd
|
| 13 |
+
import numpy as np
|
| 14 |
+
from datetime import datetime, timedelta
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class IndustryAnalyzer:
|
| 18 |
+
def __init__(self):
|
| 19 |
+
"""初始化行业分析类"""
|
| 20 |
+
self.data_cache = {}
|
| 21 |
+
self.industry_code_map = {} # 缓存行业名称到代码的映射
|
| 22 |
+
|
| 23 |
+
# 设置日志记录
|
| 24 |
+
logging.basicConfig(level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
| 26 |
+
self.logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
def get_industry_fund_flow(self, symbol="即时"):
|
| 29 |
+
"""获取行业资金流向数据"""
|
| 30 |
+
try:
|
| 31 |
+
# 缓存键
|
| 32 |
+
cache_key = f"industry_fund_flow_{symbol}"
|
| 33 |
+
|
| 34 |
+
# 检查缓存
|
| 35 |
+
if cache_key in self.data_cache:
|
| 36 |
+
cache_time, cached_data = self.data_cache[cache_key]
|
| 37 |
+
# 如果缓存时间在30分钟内,直接返回
|
| 38 |
+
if (datetime.now() - cache_time).total_seconds() < 1800:
|
| 39 |
+
self.logger.info(f"从缓存获取行业资金流向数据: {symbol}")
|
| 40 |
+
return cached_data
|
| 41 |
+
|
| 42 |
+
# 获取行业资金流向数据
|
| 43 |
+
self.logger.info(f"从API获取行业资金流向数据: {symbol}")
|
| 44 |
+
fund_flow_data = ak.stock_fund_flow_industry(symbol=symbol)
|
| 45 |
+
|
| 46 |
+
# 打印列名以便调试
|
| 47 |
+
self.logger.info(f"行业资金流向数据列名: {fund_flow_data.columns.tolist()}")
|
| 48 |
+
|
| 49 |
+
# 转换为字典列表
|
| 50 |
+
result = []
|
| 51 |
+
|
| 52 |
+
if symbol == "即时":
|
| 53 |
+
for _, row in fund_flow_data.iterrows():
|
| 54 |
+
try:
|
| 55 |
+
# 安全地将值转换为对应的类型
|
| 56 |
+
item = {
|
| 57 |
+
"rank": self._safe_int(row["序号"]),
|
| 58 |
+
"industry": str(row["行业"]),
|
| 59 |
+
"index": self._safe_float(row["行业指数"]),
|
| 60 |
+
"change": self._safe_percent(row["行业-涨跌幅"]),
|
| 61 |
+
"inflow": self._safe_float(row["流入资金"]),
|
| 62 |
+
"outflow": self._safe_float(row["流出资金"]),
|
| 63 |
+
"netFlow": self._safe_float(row["净额"]),
|
| 64 |
+
"companyCount": self._safe_int(row["公司家数"])
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# 添加领涨股相关数据,如果存在
|
| 68 |
+
if "领涨股" in row:
|
| 69 |
+
item["leadingStock"] = str(row["领涨股"])
|
| 70 |
+
if "领涨股-涨跌幅" in row:
|
| 71 |
+
item["leadingStockChange"] = self._safe_percent(row["领涨股-涨跌幅"])
|
| 72 |
+
if "当前价" in row:
|
| 73 |
+
item["leadingStockPrice"] = self._safe_float(row["当前价"])
|
| 74 |
+
|
| 75 |
+
result.append(item)
|
| 76 |
+
except Exception as e:
|
| 77 |
+
self.logger.warning(f"处理行业资金流向数据行时出错: {str(e)}")
|
| 78 |
+
continue
|
| 79 |
+
else:
|
| 80 |
+
for _, row in fund_flow_data.iterrows():
|
| 81 |
+
try:
|
| 82 |
+
item = {
|
| 83 |
+
"rank": self._safe_int(row["序号"]),
|
| 84 |
+
"industry": str(row["行业"]),
|
| 85 |
+
"companyCount": self._safe_int(row["公司家数"]),
|
| 86 |
+
"index": self._safe_float(row["行业指数"]),
|
| 87 |
+
"change": self._safe_percent(row["阶段涨跌幅"]),
|
| 88 |
+
"inflow": self._safe_float(row["流入资金"]),
|
| 89 |
+
"outflow": self._safe_float(row["流出资金"]),
|
| 90 |
+
"netFlow": self._safe_float(row["净额"])
|
| 91 |
+
}
|
| 92 |
+
result.append(item)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
self.logger.warning(f"处理行业资金流向数据行时出错: {str(e)}")
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
# 缓存结果
|
| 98 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 99 |
+
|
| 100 |
+
return result
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
self.logger.error(f"获取行业资金流向数据失败: {str(e)}")
|
| 104 |
+
# 返回更详细的错误信息,包括堆栈跟踪
|
| 105 |
+
import traceback
|
| 106 |
+
self.logger.error(traceback.format_exc())
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
+
def _safe_float(self, value):
|
| 110 |
+
"""安全地将值转换为浮点数"""
|
| 111 |
+
try:
|
| 112 |
+
if pd.isna(value):
|
| 113 |
+
return 0.0
|
| 114 |
+
return float(value)
|
| 115 |
+
except:
|
| 116 |
+
return 0.0
|
| 117 |
+
|
| 118 |
+
def _safe_int(self, value):
|
| 119 |
+
"""安全地将值转换为整数"""
|
| 120 |
+
try:
|
| 121 |
+
if pd.isna(value):
|
| 122 |
+
return 0
|
| 123 |
+
return int(value)
|
| 124 |
+
except:
|
| 125 |
+
return 0
|
| 126 |
+
|
| 127 |
+
def _safe_percent(self, value):
|
| 128 |
+
"""安全地将百分比值转换为字符串格式"""
|
| 129 |
+
try:
|
| 130 |
+
if pd.isna(value):
|
| 131 |
+
return "0.00"
|
| 132 |
+
|
| 133 |
+
# 如果是字符串并包含%,移除%符号
|
| 134 |
+
if isinstance(value, str) and "%" in value:
|
| 135 |
+
return value.replace("%", "")
|
| 136 |
+
|
| 137 |
+
# 如果是数值,直接转换成字符串
|
| 138 |
+
return str(float(value))
|
| 139 |
+
except:
|
| 140 |
+
return "0.00"
|
| 141 |
+
|
| 142 |
+
def _get_industry_code(self, industry_name):
|
| 143 |
+
"""获取行业名称对应的板块代码"""
|
| 144 |
+
try:
|
| 145 |
+
# 如果已经缓存了行业代码映射,直接使用
|
| 146 |
+
if not self.industry_code_map:
|
| 147 |
+
# 获取东方财富行业板块名称及代码
|
| 148 |
+
industry_list = ak.stock_board_industry_name_em()
|
| 149 |
+
|
| 150 |
+
# 创建行业名称到代码的映射
|
| 151 |
+
for _, row in industry_list.iterrows():
|
| 152 |
+
if '板块名称' in industry_list.columns and '板块代码' in industry_list.columns:
|
| 153 |
+
name = row['板块名称']
|
| 154 |
+
code = row['板块代码']
|
| 155 |
+
self.industry_code_map[name] = code
|
| 156 |
+
|
| 157 |
+
self.logger.info(f"成功获取到 {len(self.industry_code_map)} 个行业代码映射")
|
| 158 |
+
|
| 159 |
+
# 尝试精确匹配
|
| 160 |
+
if industry_name in self.industry_code_map:
|
| 161 |
+
return self.industry_code_map[industry_name]
|
| 162 |
+
|
| 163 |
+
# 尝试模糊匹配
|
| 164 |
+
for name, code in self.industry_code_map.items():
|
| 165 |
+
if industry_name in name or name in industry_name:
|
| 166 |
+
self.logger.info(f"行业名称 '{industry_name}' 模糊匹配到 '{name}',代码: {code}")
|
| 167 |
+
return code
|
| 168 |
+
|
| 169 |
+
# 如果找不到匹配项,则返回None
|
| 170 |
+
self.logger.warning(f"未找到行业 '{industry_name}' 对应的代码")
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
self.logger.error(f"获取行业代码时出错: {str(e)}")
|
| 175 |
+
import traceback
|
| 176 |
+
self.logger.error(traceback.format_exc())
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
def get_industry_stocks(self, industry):
|
| 180 |
+
"""获取行业成分股"""
|
| 181 |
+
try:
|
| 182 |
+
# 缓存键
|
| 183 |
+
cache_key = f"industry_stocks_{industry}"
|
| 184 |
+
|
| 185 |
+
# 检查缓存
|
| 186 |
+
if cache_key in self.data_cache:
|
| 187 |
+
cache_time, cached_data = self.data_cache[cache_key]
|
| 188 |
+
# 如果缓存时间在1小时内,直接返回
|
| 189 |
+
if (datetime.now() - cache_time).total_seconds() < 3600:
|
| 190 |
+
self.logger.info(f"从缓存获取行业成分股: {industry}")
|
| 191 |
+
return cached_data
|
| 192 |
+
|
| 193 |
+
# 获取行业成分股
|
| 194 |
+
self.logger.info(f"获取 {industry} 行业成分股")
|
| 195 |
+
|
| 196 |
+
result = []
|
| 197 |
+
try:
|
| 198 |
+
# 1. 首先尝试直接使用行业名称
|
| 199 |
+
try:
|
| 200 |
+
stocks = ak.stock_board_industry_cons_em(symbol=industry)
|
| 201 |
+
self.logger.info(f"使用行业名称 '{industry}' 成功获取成分股")
|
| 202 |
+
except Exception as direct_error:
|
| 203 |
+
self.logger.warning(f"使用行业名称获取成分股失败: {str(direct_error)}")
|
| 204 |
+
# 2. 尝试使用行业代码
|
| 205 |
+
industry_code = self._get_industry_code(industry)
|
| 206 |
+
if industry_code:
|
| 207 |
+
self.logger.info(f"尝试使用行业代码 {industry_code} 获取成分股")
|
| 208 |
+
stocks = ak.stock_board_industry_cons_em(symbol=industry_code)
|
| 209 |
+
else:
|
| 210 |
+
# 如果无法获取行业代码,抛出异常,进入模拟数据生成
|
| 211 |
+
raise ValueError(f"无法找到行业 '{industry}' 对应的代码")
|
| 212 |
+
|
| 213 |
+
# 打印列名以便调试
|
| 214 |
+
self.logger.info(f"行业成分股数据列名: {stocks.columns.tolist()}")
|
| 215 |
+
|
| 216 |
+
# 转换为字典列表
|
| 217 |
+
if not stocks.empty:
|
| 218 |
+
for _, row in stocks.iterrows():
|
| 219 |
+
try:
|
| 220 |
+
item = {
|
| 221 |
+
"code": str(row["代码"]),
|
| 222 |
+
"name": str(row["名称"]),
|
| 223 |
+
"price": self._safe_float(row["最新价"]),
|
| 224 |
+
"change": self._safe_float(row["涨跌幅"]),
|
| 225 |
+
"change_amount": self._safe_float(row["涨跌额"]) if "涨跌额" in row else 0.0,
|
| 226 |
+
"volume": self._safe_float(row["成交量"]) if "成交量" in row else 0.0,
|
| 227 |
+
"turnover": self._safe_float(row["成交额"]) if "成交额" in row else 0.0,
|
| 228 |
+
"amplitude": self._safe_float(row["振幅"]) if "振幅" in row else 0.0,
|
| 229 |
+
"turnover_rate": self._safe_float(row["换手率"]) if "换手率" in row else 0.0
|
| 230 |
+
}
|
| 231 |
+
result.append(item)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
self.logger.warning(f"处理行业成分股数据行时出错: {str(e)}")
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
except Exception as e:
|
| 237 |
+
# 3. 如果上述方法都失败,生成模拟数据
|
| 238 |
+
self.logger.warning(f"无法通过API获取行业成分股,使用模拟数据: {str(e)}")
|
| 239 |
+
result = self._generate_mock_industry_stocks(industry)
|
| 240 |
+
|
| 241 |
+
# 缓存结果
|
| 242 |
+
self.data_cache[cache_key] = (datetime.now(), result)
|
| 243 |
+
|
| 244 |
+
return result
|
| 245 |
+
|
| 246 |
+
except Exception as e:
|
| 247 |
+
self.logger.error(f"获取行业成分股失败: {str(e)}")
|
| 248 |
+
import traceback
|
| 249 |
+
self.logger.error(traceback.format_exc())
|
| 250 |
+
return []
|
| 251 |
+
|
| 252 |
+
def _generate_mock_industry_stocks(self, industry):
|
| 253 |
+
"""生成模拟的行业成分股数据"""
|
| 254 |
+
self.logger.info(f"生成行业 {industry} 的模拟成分股数据")
|
| 255 |
+
|
| 256 |
+
# 使用来自资金流向的行业数据获取该行业的基本信息
|
| 257 |
+
fund_flow_data = self.get_industry_fund_flow("即时")
|
| 258 |
+
industry_data = next((item for item in fund_flow_data if item["industry"] == industry), None)
|
| 259 |
+
|
| 260 |
+
company_count = 20 # 默认值
|
| 261 |
+
if industry_data and "companyCount" in industry_data:
|
| 262 |
+
company_count = min(industry_data["companyCount"], 30) # 限制最多30只股票
|
| 263 |
+
|
| 264 |
+
# 生成模拟股票
|
| 265 |
+
result = []
|
| 266 |
+
for i in range(company_count):
|
| 267 |
+
# 生成6位数字的股票代码,确保前缀是0或6
|
| 268 |
+
prefix = "6" if i % 2 == 0 else "0"
|
| 269 |
+
code = prefix + str(100000 + i).zfill(5)[-5:]
|
| 270 |
+
|
| 271 |
+
# 生成股票价格和涨跌幅
|
| 272 |
+
price = round(random.uniform(10, 100), 2)
|
| 273 |
+
change = round(random.uniform(-5, 5), 2)
|
| 274 |
+
|
| 275 |
+
# 生成成交量和成交额
|
| 276 |
+
volume = round(random.uniform(100000, 10000000))
|
| 277 |
+
turnover = round(volume * price / 10000, 2) # 转换为万元
|
| 278 |
+
|
| 279 |
+
# 生成换手率和振幅
|
| 280 |
+
turnover_rate = round(random.uniform(0.5, 5), 2)
|
| 281 |
+
amplitude = round(random.uniform(1, 10), 2)
|
| 282 |
+
|
| 283 |
+
item = {
|
| 284 |
+
"code": code,
|
| 285 |
+
"name": f"{industry}股{i + 1}",
|
| 286 |
+
"price": price,
|
| 287 |
+
"change": change,
|
| 288 |
+
"change_amount": round(price * change / 100, 2),
|
| 289 |
+
"volume": volume,
|
| 290 |
+
"turnover": turnover,
|
| 291 |
+
"amplitude": amplitude,
|
| 292 |
+
"turnover_rate": turnover_rate
|
| 293 |
+
}
|
| 294 |
+
result.append(item)
|
| 295 |
+
|
| 296 |
+
# 按涨跌幅排序
|
| 297 |
+
result.sort(key=lambda x: x["change"], reverse=True)
|
| 298 |
+
|
| 299 |
+
return result
|
| 300 |
+
|
| 301 |
+
def get_industry_detail(self, industry):
|
| 302 |
+
"""获取行业详细信息"""
|
| 303 |
+
try:
|
| 304 |
+
# 获取行业资金流向数据
|
| 305 |
+
fund_flow_data = self.get_industry_fund_flow("即时")
|
| 306 |
+
industry_data = next((item for item in fund_flow_data if item["industry"] == industry), None)
|
| 307 |
+
|
| 308 |
+
if not industry_data:
|
| 309 |
+
return None
|
| 310 |
+
|
| 311 |
+
# 获取历史资金流向数据
|
| 312 |
+
history_data = []
|
| 313 |
+
|
| 314 |
+
for period in ["3日排行", "5日排行", "10日排行", "20日排行"]:
|
| 315 |
+
period_data = self.get_industry_fund_flow(period)
|
| 316 |
+
industry_period_data = next((item for item in period_data if item["industry"] == industry), None)
|
| 317 |
+
|
| 318 |
+
if industry_period_data:
|
| 319 |
+
days = int(period.replace("日排行", ""))
|
| 320 |
+
date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
| 321 |
+
|
| 322 |
+
history_data.append({
|
| 323 |
+
"date": date,
|
| 324 |
+
"inflow": industry_period_data["inflow"],
|
| 325 |
+
"outflow": industry_period_data["outflow"],
|
| 326 |
+
"netFlow": industry_period_data["netFlow"],
|
| 327 |
+
"change": industry_period_data["change"]
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
# 添加即时数据
|
| 331 |
+
history_data.append({
|
| 332 |
+
"date": datetime.now().strftime("%Y-%m-%d"),
|
| 333 |
+
"inflow": industry_data["inflow"],
|
| 334 |
+
"outflow": industry_data["outflow"],
|
| 335 |
+
"netFlow": industry_data["netFlow"],
|
| 336 |
+
"change": industry_data["change"]
|
| 337 |
+
})
|
| 338 |
+
|
| 339 |
+
# 按日期排序
|
| 340 |
+
history_data.sort(key=lambda x: x["date"])
|
| 341 |
+
|
| 342 |
+
# 计算行��评分
|
| 343 |
+
score = self.calculate_industry_score(industry_data, history_data)
|
| 344 |
+
|
| 345 |
+
# 生成投资建议
|
| 346 |
+
recommendation = self.generate_industry_recommendation(score, industry_data, history_data)
|
| 347 |
+
|
| 348 |
+
# 构建结果
|
| 349 |
+
result = {
|
| 350 |
+
"industry": industry,
|
| 351 |
+
"index": industry_data["index"],
|
| 352 |
+
"change": industry_data["change"],
|
| 353 |
+
"companyCount": industry_data["companyCount"],
|
| 354 |
+
"inflow": industry_data["inflow"],
|
| 355 |
+
"outflow": industry_data["outflow"],
|
| 356 |
+
"netFlow": industry_data["netFlow"],
|
| 357 |
+
"leadingStock": industry_data.get("leadingStock", ""),
|
| 358 |
+
"leadingStockChange": industry_data.get("leadingStockChange", ""),
|
| 359 |
+
"leadingStockPrice": industry_data.get("leadingStockPrice", 0),
|
| 360 |
+
"score": score,
|
| 361 |
+
"recommendation": recommendation,
|
| 362 |
+
"flowHistory": history_data
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
return result
|
| 366 |
+
|
| 367 |
+
except Exception as e:
|
| 368 |
+
self.logger.error(f"获取行业详细信息失败: {str(e)}")
|
| 369 |
+
import traceback
|
| 370 |
+
self.logger.error(traceback.format_exc())
|
| 371 |
+
return None
|
| 372 |
+
|
| 373 |
+
def calculate_industry_score(self, industry_data, history_data):
|
| 374 |
+
"""计算行业评分"""
|
| 375 |
+
try:
|
| 376 |
+
# 基础分数为50分
|
| 377 |
+
score = 50
|
| 378 |
+
|
| 379 |
+
# 根据涨跌幅增减分数(-10到+10)
|
| 380 |
+
change = float(industry_data["change"])
|
| 381 |
+
if change > 3:
|
| 382 |
+
score += 10
|
| 383 |
+
elif change > 1:
|
| 384 |
+
score += 5
|
| 385 |
+
elif change < -3:
|
| 386 |
+
score -= 10
|
| 387 |
+
elif change < -1:
|
| 388 |
+
score -= 5
|
| 389 |
+
|
| 390 |
+
# 根据资金流向增减分数(-20到+20)
|
| 391 |
+
netFlow = float(industry_data["netFlow"])
|
| 392 |
+
|
| 393 |
+
if netFlow > 5:
|
| 394 |
+
score += 20
|
| 395 |
+
elif netFlow > 2:
|
| 396 |
+
score += 15
|
| 397 |
+
elif netFlow > 0:
|
| 398 |
+
score += 10
|
| 399 |
+
elif netFlow < -5:
|
| 400 |
+
score -= 20
|
| 401 |
+
elif netFlow < -2:
|
| 402 |
+
score -= 15
|
| 403 |
+
elif netFlow < 0:
|
| 404 |
+
score -= 10
|
| 405 |
+
|
| 406 |
+
# 根据历史资金流向趋势增减分数(-10到+10)
|
| 407 |
+
if len(history_data) >= 2:
|
| 408 |
+
net_flow_trend = 0
|
| 409 |
+
for i in range(1, len(history_data)):
|
| 410 |
+
if float(history_data[i]["netFlow"]) > float(history_data[i - 1]["netFlow"]):
|
| 411 |
+
net_flow_trend += 1
|
| 412 |
+
else:
|
| 413 |
+
net_flow_trend -= 1
|
| 414 |
+
|
| 415 |
+
if net_flow_trend > 0:
|
| 416 |
+
score += 10
|
| 417 |
+
elif net_flow_trend < 0:
|
| 418 |
+
score -= 10
|
| 419 |
+
|
| 420 |
+
# 限制分数在0-100之间
|
| 421 |
+
score = max(0, min(100, score))
|
| 422 |
+
|
| 423 |
+
return round(score)
|
| 424 |
+
|
| 425 |
+
except Exception as e:
|
| 426 |
+
self.logger.error(f"计算行业评分时出错: {str(e)}")
|
| 427 |
+
return 50
|
| 428 |
+
|
| 429 |
+
def generate_industry_recommendation(self, score, industry_data, history_data):
|
| 430 |
+
"""生成行业投资建议"""
|
| 431 |
+
try:
|
| 432 |
+
if score >= 80:
|
| 433 |
+
return "行业景气度高,资金持续流入,建议积极配置"
|
| 434 |
+
elif score >= 60:
|
| 435 |
+
return "行业表现良好,建议适当加仓"
|
| 436 |
+
elif score >= 40:
|
| 437 |
+
return "行业表现一般,建议谨慎参与"
|
| 438 |
+
else:
|
| 439 |
+
return "行业下行趋势明显,建议减持规避风险"
|
| 440 |
+
|
| 441 |
+
except Exception as e:
|
| 442 |
+
self.logger.error(f"生成行业投资建议时出错: {str(e)}")
|
| 443 |
+
return "无法生成投资建议"
|
| 444 |
+
|
| 445 |
+
def compare_industries(self, limit=10):
|
| 446 |
+
"""比较不同行业的表现"""
|
| 447 |
+
try:
|
| 448 |
+
# 获取行业板块数据
|
| 449 |
+
industry_data = ak.stock_board_industry_name_em()
|
| 450 |
+
|
| 451 |
+
# 提取行业名称列表
|
| 452 |
+
industries = industry_data['板块名称'].tolist() if '板块名称' in industry_data.columns else []
|
| 453 |
+
|
| 454 |
+
if not industries:
|
| 455 |
+
return {"error": "获取行业列表失败"}
|
| 456 |
+
|
| 457 |
+
# 限制分析的行业数量
|
| 458 |
+
industries = industries[:limit] if limit else industries
|
| 459 |
+
|
| 460 |
+
# 分析各行业情况
|
| 461 |
+
industry_results = []
|
| 462 |
+
|
| 463 |
+
for industry in industries:
|
| 464 |
+
try:
|
| 465 |
+
# 尝试获取行业板块代码
|
| 466 |
+
industry_code = None
|
| 467 |
+
for _, row in industry_data.iterrows():
|
| 468 |
+
if row['板块名称'] == industry:
|
| 469 |
+
industry_code = row['板块代码']
|
| 470 |
+
break
|
| 471 |
+
|
| 472 |
+
if not industry_code:
|
| 473 |
+
self.logger.warning(f"未找到行业 {industry} 的板块代码")
|
| 474 |
+
continue
|
| 475 |
+
|
| 476 |
+
# 尝试使用不同的参数来获取行业数据 - 不使用"3m"
|
| 477 |
+
try:
|
| 478 |
+
# 尝试不使用period参数
|
| 479 |
+
industry_info = ak.stock_board_industry_hist_em(symbol=industry_code)
|
| 480 |
+
except Exception as e1:
|
| 481 |
+
try:
|
| 482 |
+
# 尝试使用daily参数
|
| 483 |
+
industry_info = ak.stock_board_industry_hist_em(symbol=industry_code, period="daily")
|
| 484 |
+
except Exception as e2:
|
| 485 |
+
self.logger.warning(f"分析行业 {industry} 历史数据失败: {str(e1)}, {str(e2)}")
|
| 486 |
+
continue
|
| 487 |
+
|
| 488 |
+
# 计算行业涨跌幅
|
| 489 |
+
if not industry_info.empty:
|
| 490 |
+
latest = industry_info.iloc[0]
|
| 491 |
+
|
| 492 |
+
# 尝试获取涨跌幅,列名可能有变化
|
| 493 |
+
change = 0.0
|
| 494 |
+
if '涨跌幅' in latest.index:
|
| 495 |
+
change = latest['涨跌幅']
|
| 496 |
+
elif '涨跌幅' in industry_info.columns:
|
| 497 |
+
change = latest['涨跌幅']
|
| 498 |
+
|
| 499 |
+
# 尝试获取成交量和成交额
|
| 500 |
+
volume = 0.0
|
| 501 |
+
turnover = 0.0
|
| 502 |
+
if '成交量' in latest.index:
|
| 503 |
+
volume = latest['成交量']
|
| 504 |
+
elif '成交量' in industry_info.columns:
|
| 505 |
+
volume = latest['成交量']
|
| 506 |
+
|
| 507 |
+
if '成交额' in latest.index:
|
| 508 |
+
turnover = latest['成交额']
|
| 509 |
+
elif '成交额' in industry_info.columns:
|
| 510 |
+
turnover = latest['成交额']
|
| 511 |
+
|
| 512 |
+
industry_results.append({
|
| 513 |
+
"industry": industry,
|
| 514 |
+
"change": float(change) if change else 0.0,
|
| 515 |
+
"volume": float(volume) if volume else 0.0,
|
| 516 |
+
"turnover": float(turnover) if turnover else 0.0
|
| 517 |
+
})
|
| 518 |
+
except Exception as e:
|
| 519 |
+
self.logger.error(f"分析行业 {industry} 时出错: {str(e)}")
|
| 520 |
+
|
| 521 |
+
# 按涨跌幅排序
|
| 522 |
+
industry_results.sort(key=lambda x: x.get('change', 0), reverse=True)
|
| 523 |
+
|
| 524 |
+
return {
|
| 525 |
+
"count": len(industry_results),
|
| 526 |
+
"top_industries": industry_results[:5] if len(industry_results) >= 5 else industry_results,
|
| 527 |
+
"bottom_industries": industry_results[-5:] if len(industry_results) >= 5 else [],
|
| 528 |
+
"results": industry_results
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
except Exception as e:
|
| 532 |
+
self.logger.error(f"比较行业表现时出错: {str(e)}")
|
| 533 |
+
return {"error": f"比较行业表现时出错: {str(e)}"}
|
industry_api_endpoints.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
开发者:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# industry_api_endpoints.py
|
| 9 |
+
# 预留接口
|
requirements.txt
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy>=1.24.0
|
| 2 |
+
pandas==2.2.2
|
| 3 |
+
scipy>=1.13.0,<1.14.0
|
| 4 |
+
akshare==1.15.94
|
| 5 |
+
tqdm==4.67.1
|
| 6 |
+
openai==0.28.0
|
| 7 |
+
requests==2.32.3
|
| 8 |
+
python-dotenv==1.0.1
|
| 9 |
+
flask==3.1.0
|
| 10 |
+
loguru==0.7.2
|
| 11 |
+
matplotlib==3.9.2
|
| 12 |
+
seaborn==0.13.2
|
| 13 |
+
ipython>=7.34.0
|
| 14 |
+
beautifulsoup4==4.12.3
|
| 15 |
+
html5lib==1.1
|
| 16 |
+
lxml==4.9.4
|
| 17 |
+
jsonpath==0.82.2
|
| 18 |
+
openpyxl==3.1.5
|
| 19 |
+
flask_swagger_ui
|
| 20 |
+
sqlalchemy
|
| 21 |
+
flask-cors
|
| 22 |
+
flask-caching
|
| 23 |
+
# 新增依赖
|
| 24 |
+
gunicorn==20.1.0 # 生产环境WSGI服务器
|
| 25 |
+
PyYAML==6.0 # YAML支持
|
| 26 |
+
scikit-learn==1.2.2 # 机器学习库(用于预测模型)
|
| 27 |
+
statsmodels==0.13.5 # 统计模型(用于时间序列分析)
|
| 28 |
+
pytest==7.3.1 # 测试框架
|
| 29 |
+
|
| 30 |
+
# 部署工具
|
| 31 |
+
supervisor==4.2.5 # 进程管理
|
| 32 |
+
redis==4.5.4 # 可选的缓存后端
|
risk_monitor.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
开发者:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# risk_monitor.py
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import numpy as np
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
|
| 13 |
+
class RiskMonitor:
|
| 14 |
+
def __init__(self, analyzer):
|
| 15 |
+
self.analyzer = analyzer
|
| 16 |
+
|
| 17 |
+
def analyze_stock_risk(self, stock_code, market_type='A'):
|
| 18 |
+
"""分析单只股票的风险"""
|
| 19 |
+
try:
|
| 20 |
+
# 获取股票数据和技术指标
|
| 21 |
+
df = self.analyzer.get_stock_data(stock_code, market_type)
|
| 22 |
+
df = self.analyzer.calculate_indicators(df)
|
| 23 |
+
|
| 24 |
+
# 计算各类风险指标
|
| 25 |
+
volatility_risk = self._analyze_volatility_risk(df)
|
| 26 |
+
trend_risk = self._analyze_trend_risk(df)
|
| 27 |
+
reversal_risk = self._analyze_reversal_risk(df)
|
| 28 |
+
volume_risk = self._analyze_volume_risk(df)
|
| 29 |
+
|
| 30 |
+
# 综合评估总体风险
|
| 31 |
+
total_risk_score = (
|
| 32 |
+
volatility_risk['score'] * 0.3 +
|
| 33 |
+
trend_risk['score'] * 0.3 +
|
| 34 |
+
reversal_risk['score'] * 0.25 +
|
| 35 |
+
volume_risk['score'] * 0.15
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# 确定风险等级
|
| 39 |
+
if total_risk_score >= 80:
|
| 40 |
+
risk_level = "极高"
|
| 41 |
+
elif total_risk_score >= 60:
|
| 42 |
+
risk_level = "高"
|
| 43 |
+
elif total_risk_score >= 40:
|
| 44 |
+
risk_level = "中等"
|
| 45 |
+
elif total_risk_score >= 20:
|
| 46 |
+
risk_level = "低"
|
| 47 |
+
else:
|
| 48 |
+
risk_level = "极低"
|
| 49 |
+
|
| 50 |
+
# 生成风险警报
|
| 51 |
+
alerts = []
|
| 52 |
+
|
| 53 |
+
if volatility_risk['score'] >= 70:
|
| 54 |
+
alerts.append({
|
| 55 |
+
"type": "volatility",
|
| 56 |
+
"level": "高",
|
| 57 |
+
"message": f"波动率风险较高 ({volatility_risk['value']:.2f}%),可能面临大幅波动"
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
if trend_risk['score'] >= 70:
|
| 61 |
+
alerts.append({
|
| 62 |
+
"type": "trend",
|
| 63 |
+
"level": "高",
|
| 64 |
+
"message": f"趋势风险较高,当前处于{trend_risk['trend']}趋势,可能面临加速下跌"
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
if reversal_risk['score'] >= 70:
|
| 68 |
+
alerts.append({
|
| 69 |
+
"type": "reversal",
|
| 70 |
+
"level": "高",
|
| 71 |
+
"message": f"趋势反转风险较高,技术指标显示可能{reversal_risk['direction']}反转"
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
if volume_risk['score'] >= 70:
|
| 75 |
+
alerts.append({
|
| 76 |
+
"type": "volume",
|
| 77 |
+
"level": "高",
|
| 78 |
+
"message": f"成交量异常,{volume_risk['pattern']},可能预示价格波动"
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"total_risk_score": total_risk_score,
|
| 83 |
+
"risk_level": risk_level,
|
| 84 |
+
"volatility_risk": volatility_risk,
|
| 85 |
+
"trend_risk": trend_risk,
|
| 86 |
+
"reversal_risk": reversal_risk,
|
| 87 |
+
"volume_risk": volume_risk,
|
| 88 |
+
"alerts": alerts
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f"分析股票风险出错: {str(e)}")
|
| 93 |
+
return {
|
| 94 |
+
"error": f"分析风险时出错: {str(e)}"
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def _analyze_volatility_risk(self, df):
|
| 98 |
+
"""分析波动率风险"""
|
| 99 |
+
# 计算近期波动率
|
| 100 |
+
recent_volatility = df.iloc[-1]['Volatility']
|
| 101 |
+
|
| 102 |
+
# 计算波动率变化
|
| 103 |
+
avg_volatility = df['Volatility'].mean()
|
| 104 |
+
volatility_change = recent_volatility / avg_volatility - 1
|
| 105 |
+
|
| 106 |
+
# 评估风险分数
|
| 107 |
+
if recent_volatility > 5 and volatility_change > 0.5:
|
| 108 |
+
score = 90 # 极高风险
|
| 109 |
+
elif recent_volatility > 4 and volatility_change > 0.3:
|
| 110 |
+
score = 75 # 高风险
|
| 111 |
+
elif recent_volatility > 3 and volatility_change > 0.1:
|
| 112 |
+
score = 60 # 中高风险
|
| 113 |
+
elif recent_volatility > 2:
|
| 114 |
+
score = 40 # 中等风险
|
| 115 |
+
elif recent_volatility > 1:
|
| 116 |
+
score = 20 # 低风险
|
| 117 |
+
else:
|
| 118 |
+
score = 0 # 极低风险
|
| 119 |
+
|
| 120 |
+
return {
|
| 121 |
+
"score": score,
|
| 122 |
+
"value": recent_volatility,
|
| 123 |
+
"change": volatility_change * 100,
|
| 124 |
+
"risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
def _analyze_trend_risk(self, df):
|
| 128 |
+
"""分析趋势风险"""
|
| 129 |
+
# 获取均线数据
|
| 130 |
+
ma5 = df.iloc[-1]['MA5']
|
| 131 |
+
ma20 = df.iloc[-1]['MA20']
|
| 132 |
+
ma60 = df.iloc[-1]['MA60']
|
| 133 |
+
|
| 134 |
+
# 判断当前趋势
|
| 135 |
+
if ma5 < ma20 < ma60:
|
| 136 |
+
trend = "下降"
|
| 137 |
+
|
| 138 |
+
# 判断下跌加速程度
|
| 139 |
+
ma5_ma20_gap = (ma20 - ma5) / ma20 * 100
|
| 140 |
+
|
| 141 |
+
if ma5_ma20_gap > 5:
|
| 142 |
+
score = 90 # 极高风险
|
| 143 |
+
elif ma5_ma20_gap > 3:
|
| 144 |
+
score = 75 # 高风险
|
| 145 |
+
elif ma5_ma20_gap > 1:
|
| 146 |
+
score = 60 # 中高风险
|
| 147 |
+
else:
|
| 148 |
+
score = 50 # 中等风险
|
| 149 |
+
|
| 150 |
+
elif ma5 > ma20 > ma60:
|
| 151 |
+
trend = "上升"
|
| 152 |
+
score = 20 # 低风险
|
| 153 |
+
else:
|
| 154 |
+
trend = "盘整"
|
| 155 |
+
score = 40 # 中等风险
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
"score": score,
|
| 159 |
+
"trend": trend,
|
| 160 |
+
"risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
def _analyze_reversal_risk(self, df):
|
| 164 |
+
"""分析趋势反转风险"""
|
| 165 |
+
# 获取最新指标
|
| 166 |
+
rsi = df.iloc[-1]['RSI']
|
| 167 |
+
macd = df.iloc[-1]['MACD']
|
| 168 |
+
signal = df.iloc[-1]['Signal']
|
| 169 |
+
price = df.iloc[-1]['close']
|
| 170 |
+
ma20 = df.iloc[-1]['MA20']
|
| 171 |
+
|
| 172 |
+
# 判断潜在趋势反转信号
|
| 173 |
+
reversal_signals = 0
|
| 174 |
+
|
| 175 |
+
# RSI超买/超卖
|
| 176 |
+
if rsi > 75:
|
| 177 |
+
reversal_signals += 1
|
| 178 |
+
direction = "向下"
|
| 179 |
+
elif rsi < 25:
|
| 180 |
+
reversal_signals += 1
|
| 181 |
+
direction = "向上"
|
| 182 |
+
else:
|
| 183 |
+
direction = "无明确方向"
|
| 184 |
+
|
| 185 |
+
# MACD死叉/金叉
|
| 186 |
+
if macd > signal and df.iloc[-2]['MACD'] <= df.iloc[-2]['Signal']:
|
| 187 |
+
reversal_signals += 1
|
| 188 |
+
direction = "向上"
|
| 189 |
+
elif macd < signal and df.iloc[-2]['MACD'] >= df.iloc[-2]['Signal']:
|
| 190 |
+
reversal_signals += 1
|
| 191 |
+
direction = "向下"
|
| 192 |
+
|
| 193 |
+
# 价格与均线关系
|
| 194 |
+
if price > ma20 * 1.1:
|
| 195 |
+
reversal_signals += 1
|
| 196 |
+
direction = "向下"
|
| 197 |
+
elif price < ma20 * 0.9:
|
| 198 |
+
reversal_signals += 1
|
| 199 |
+
direction = "向上"
|
| 200 |
+
|
| 201 |
+
# 评估风险分数
|
| 202 |
+
if reversal_signals >= 3:
|
| 203 |
+
score = 90 # 极高风险
|
| 204 |
+
elif reversal_signals == 2:
|
| 205 |
+
score = 70 # 高风险
|
| 206 |
+
elif reversal_signals == 1:
|
| 207 |
+
score = 40 # 中等风险
|
| 208 |
+
else:
|
| 209 |
+
score = 10 # 低风险
|
| 210 |
+
|
| 211 |
+
return {
|
| 212 |
+
"score": score,
|
| 213 |
+
"reversal_signals": reversal_signals,
|
| 214 |
+
"direction": direction,
|
| 215 |
+
"risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
def _analyze_volume_risk(self, df):
|
| 219 |
+
"""分析成交量风险"""
|
| 220 |
+
# 计算成交量变化
|
| 221 |
+
recent_volume = df.iloc[-1]['volume']
|
| 222 |
+
avg_volume = df['volume'].rolling(window=20).mean().iloc[-1]
|
| 223 |
+
volume_ratio = recent_volume / avg_volume
|
| 224 |
+
|
| 225 |
+
# 判断成交量模式
|
| 226 |
+
if volume_ratio > 3:
|
| 227 |
+
pattern = "成交量暴增"
|
| 228 |
+
score = 90 # 极高风险
|
| 229 |
+
elif volume_ratio > 2:
|
| 230 |
+
pattern = "成交量显著放大"
|
| 231 |
+
score = 70 # 高风险
|
| 232 |
+
elif volume_ratio > 1.5:
|
| 233 |
+
pattern = "成交量温和放大"
|
| 234 |
+
score = 50 # 中等风险
|
| 235 |
+
elif volume_ratio < 0.5:
|
| 236 |
+
pattern = "成交量萎缩"
|
| 237 |
+
score = 40 # 中低风险
|
| 238 |
+
else:
|
| 239 |
+
pattern = "成交量正常"
|
| 240 |
+
score = 20 # 低风险
|
| 241 |
+
|
| 242 |
+
# 价格与成交量背离分析
|
| 243 |
+
price_change = (df.iloc[-1]['close'] - df.iloc[-5]['close']) / df.iloc[-5]['close']
|
| 244 |
+
volume_change = (recent_volume - df.iloc[-5]['volume']) / df.iloc[-5]['volume']
|
| 245 |
+
|
| 246 |
+
if price_change > 0.05 and volume_change < -0.3:
|
| 247 |
+
pattern = "价量背离(价格上涨但量能萎缩)"
|
| 248 |
+
score = max(score, 80) # 提高风险评分
|
| 249 |
+
elif price_change < -0.05 and volume_change < -0.3:
|
| 250 |
+
pattern = "价量同向(价格下跌且量能萎缩)"
|
| 251 |
+
score = max(score, 70) # 提高风险评分
|
| 252 |
+
elif price_change < -0.05 and volume_change > 0.5:
|
| 253 |
+
pattern = "价量同向(价格下跌且量能放大)"
|
| 254 |
+
score = max(score, 85) # 提高风险评分
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"score": score,
|
| 258 |
+
"volume_ratio": volume_ratio,
|
| 259 |
+
"pattern": pattern,
|
| 260 |
+
"risk_level": "高" if score >= 60 else "中" if score >= 30 else "低"
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
def analyze_portfolio_risk(self, portfolio):
|
| 264 |
+
"""分析投资组合整体风险"""
|
| 265 |
+
try:
|
| 266 |
+
if not portfolio or len(portfolio) == 0:
|
| 267 |
+
return {"error": "投资组合为空"}
|
| 268 |
+
|
| 269 |
+
# 分析每只股票的风险
|
| 270 |
+
stock_risks = {}
|
| 271 |
+
total_weight = 0
|
| 272 |
+
weighted_risk_score = 0
|
| 273 |
+
|
| 274 |
+
for stock in portfolio:
|
| 275 |
+
stock_code = stock.get('stock_code')
|
| 276 |
+
weight = stock.get('weight', 1)
|
| 277 |
+
market_type = stock.get('market_type', 'A')
|
| 278 |
+
|
| 279 |
+
if not stock_code:
|
| 280 |
+
continue
|
| 281 |
+
|
| 282 |
+
# 分析股票风险
|
| 283 |
+
risk = self.analyze_stock_risk(stock_code, market_type)
|
| 284 |
+
stock_risks[stock_code] = risk
|
| 285 |
+
|
| 286 |
+
# 计算加权风险分数
|
| 287 |
+
total_weight += weight
|
| 288 |
+
weighted_risk_score += risk.get('total_risk_score', 50) * weight
|
| 289 |
+
|
| 290 |
+
# 计算组合总风险分数
|
| 291 |
+
if total_weight > 0:
|
| 292 |
+
portfolio_risk_score = weighted_risk_score / total_weight
|
| 293 |
+
else:
|
| 294 |
+
portfolio_risk_score = 0
|
| 295 |
+
|
| 296 |
+
# 确定风险等级
|
| 297 |
+
if portfolio_risk_score >= 80:
|
| 298 |
+
risk_level = "极高"
|
| 299 |
+
elif portfolio_risk_score >= 60:
|
| 300 |
+
risk_level = "高"
|
| 301 |
+
elif portfolio_risk_score >= 40:
|
| 302 |
+
risk_level = "中等"
|
| 303 |
+
elif portfolio_risk_score >= 20:
|
| 304 |
+
risk_level = "低"
|
| 305 |
+
else:
|
| 306 |
+
risk_level = "极低"
|
| 307 |
+
|
| 308 |
+
# 收集高风险股票
|
| 309 |
+
high_risk_stocks = [
|
| 310 |
+
{
|
| 311 |
+
"stock_code": code,
|
| 312 |
+
"risk_score": risk.get('total_risk_score', 0),
|
| 313 |
+
"risk_level": risk.get('risk_level', '未知')
|
| 314 |
+
}
|
| 315 |
+
for code, risk in stock_risks.items()
|
| 316 |
+
if risk.get('total_risk_score', 0) >= 60
|
| 317 |
+
]
|
| 318 |
+
|
| 319 |
+
# 收集所有风险警报
|
| 320 |
+
all_alerts = []
|
| 321 |
+
for code, risk in stock_risks.items():
|
| 322 |
+
for alert in risk.get('alerts', []):
|
| 323 |
+
all_alerts.append({
|
| 324 |
+
"stock_code": code,
|
| 325 |
+
**alert
|
| 326 |
+
})
|
| 327 |
+
|
| 328 |
+
# 分析风险集中度
|
| 329 |
+
risk_concentration = self._analyze_risk_concentration(portfolio, stock_risks)
|
| 330 |
+
|
| 331 |
+
return {
|
| 332 |
+
"portfolio_risk_score": portfolio_risk_score,
|
| 333 |
+
"risk_level": risk_level,
|
| 334 |
+
"high_risk_stocks": high_risk_stocks,
|
| 335 |
+
"alerts": all_alerts,
|
| 336 |
+
"risk_concentration": risk_concentration,
|
| 337 |
+
"stock_risks": stock_risks
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
except Exception as e:
|
| 341 |
+
print(f"分析投资组合风险出错: {str(e)}")
|
| 342 |
+
return {
|
| 343 |
+
"error": f"分析投资组合风险时出错: {str(e)}"
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
def _analyze_risk_concentration(self, portfolio, stock_risks):
|
| 347 |
+
"""分析风险集中度"""
|
| 348 |
+
# 分析行业集中度
|
| 349 |
+
industries = {}
|
| 350 |
+
for stock in portfolio:
|
| 351 |
+
stock_code = stock.get('stock_code')
|
| 352 |
+
stock_info = self.analyzer.get_stock_info(stock_code)
|
| 353 |
+
industry = stock_info.get('行业', '未知')
|
| 354 |
+
weight = stock.get('weight', 1)
|
| 355 |
+
|
| 356 |
+
if industry in industries:
|
| 357 |
+
industries[industry] += weight
|
| 358 |
+
else:
|
| 359 |
+
industries[industry] = weight
|
| 360 |
+
|
| 361 |
+
# 找出权重最大的行业
|
| 362 |
+
max_industry = max(industries.items(), key=lambda x: x[1]) if industries else ('未知', 0)
|
| 363 |
+
|
| 364 |
+
# 计算高风险股票总权重
|
| 365 |
+
high_risk_weight = 0
|
| 366 |
+
for stock in portfolio:
|
| 367 |
+
stock_code = stock.get('stock_code')
|
| 368 |
+
if stock_code in stock_risks and stock_risks[stock_code].get('total_risk_score', 0) >= 60:
|
| 369 |
+
high_risk_weight += stock.get('weight', 1)
|
| 370 |
+
|
| 371 |
+
return {
|
| 372 |
+
"max_industry": max_industry[0],
|
| 373 |
+
"max_industry_weight": max_industry[1],
|
| 374 |
+
"high_risk_weight": high_risk_weight
|
| 375 |
+
}
|
scenario_predictor.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
开发者:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# scenario_predictor.py
|
| 9 |
+
import os
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
import openai
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
class ScenarioPredictor:
|
| 19 |
+
def __init__(self, analyzer, openai_api_key=None, openai_model=None):
|
| 20 |
+
self.analyzer = analyzer
|
| 21 |
+
self.openai_api_key = os.getenv('OPENAI_API_KEY', os.getenv('OPENAI_API_KEY'))
|
| 22 |
+
self.openai_api_url = os.getenv('OPENAI_API_URL')
|
| 23 |
+
self.openai_model = os.getenv('OPENAI_API_MODEL', 'gemini-2.0-pro-exp-02-05')
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def generate_scenarios(self, stock_code, market_type='A', days=60):
|
| 27 |
+
"""生成乐观、中性、悲观三种市场情景预测"""
|
| 28 |
+
try:
|
| 29 |
+
# 获取股票数据和技术指标
|
| 30 |
+
df = self.analyzer.get_stock_data(stock_code, market_type)
|
| 31 |
+
df = self.analyzer.calculate_indicators(df)
|
| 32 |
+
|
| 33 |
+
# 获取股票信息
|
| 34 |
+
stock_info = self.analyzer.get_stock_info(stock_code)
|
| 35 |
+
|
| 36 |
+
# 计算基础数据
|
| 37 |
+
current_price = df.iloc[-1]['close']
|
| 38 |
+
avg_volatility = df['Volatility'].mean()
|
| 39 |
+
|
| 40 |
+
# 根据历史波动率计算情景
|
| 41 |
+
scenarios = self._calculate_scenarios(df, days)
|
| 42 |
+
|
| 43 |
+
# 使用AI生成各情景的分析
|
| 44 |
+
if self.openai_api_key:
|
| 45 |
+
ai_analysis = self._generate_ai_analysis(stock_code, stock_info, df, scenarios)
|
| 46 |
+
scenarios.update(ai_analysis)
|
| 47 |
+
|
| 48 |
+
return scenarios
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"生成情景预测出错: {str(e)}")
|
| 51 |
+
return {}
|
| 52 |
+
|
| 53 |
+
def _calculate_scenarios(self, df, days):
|
| 54 |
+
"""基于历史数据计算三种情景的价格预测"""
|
| 55 |
+
current_price = df.iloc[-1]['close']
|
| 56 |
+
|
| 57 |
+
# 计算历史波动率和移动均线
|
| 58 |
+
volatility = df['Volatility'].mean() / 100 # 转换为小数
|
| 59 |
+
daily_volatility = volatility / np.sqrt(252) # 转换为日波动率
|
| 60 |
+
ma20 = df.iloc[-1]['MA20']
|
| 61 |
+
ma60 = df.iloc[-1]['MA60']
|
| 62 |
+
|
| 63 |
+
# 计算乐观情景(上涨至压力位或突破)
|
| 64 |
+
optimistic_return = 0.15 # 15%上涨
|
| 65 |
+
if df.iloc[-1]['BB_upper'] > current_price:
|
| 66 |
+
optimistic_target = df.iloc[-1]['BB_upper'] * 1.05 # 突破上轨5%
|
| 67 |
+
else:
|
| 68 |
+
optimistic_target = current_price * (1 + optimistic_return)
|
| 69 |
+
|
| 70 |
+
# 计算中性情景(震荡,围绕当前价格或20日均线波动)
|
| 71 |
+
neutral_target = (current_price + ma20) / 2
|
| 72 |
+
|
| 73 |
+
# 计算悲观情景(下跌至支撑位或跌破)
|
| 74 |
+
pessimistic_return = -0.12 # 12%下跌
|
| 75 |
+
if df.iloc[-1]['BB_lower'] < current_price:
|
| 76 |
+
pessimistic_target = df.iloc[-1]['BB_lower'] * 0.95 # 跌破下轨5%
|
| 77 |
+
else:
|
| 78 |
+
pessimistic_target = current_price * (1 + pessimistic_return)
|
| 79 |
+
|
| 80 |
+
# 计算预期时间
|
| 81 |
+
time_periods = np.arange(1, days + 1)
|
| 82 |
+
|
| 83 |
+
# 生成乐观路径
|
| 84 |
+
opt_path = [current_price]
|
| 85 |
+
for _ in range(days):
|
| 86 |
+
daily_return = (optimistic_target / current_price) ** (1 / days) - 1
|
| 87 |
+
random_component = np.random.normal(0, daily_volatility)
|
| 88 |
+
new_price = opt_path[-1] * (1 + daily_return + random_component / 2)
|
| 89 |
+
opt_path.append(new_price)
|
| 90 |
+
|
| 91 |
+
# 生成中性路径
|
| 92 |
+
neu_path = [current_price]
|
| 93 |
+
for _ in range(days):
|
| 94 |
+
daily_return = (neutral_target / current_price) ** (1 / days) - 1
|
| 95 |
+
random_component = np.random.normal(0, daily_volatility)
|
| 96 |
+
new_price = neu_path[-1] * (1 + daily_return + random_component)
|
| 97 |
+
neu_path.append(new_price)
|
| 98 |
+
|
| 99 |
+
# 生成悲观路径
|
| 100 |
+
pes_path = [current_price]
|
| 101 |
+
for _ in range(days):
|
| 102 |
+
daily_return = (pessimistic_target / current_price) ** (1 / days) - 1
|
| 103 |
+
random_component = np.random.normal(0, daily_volatility)
|
| 104 |
+
new_price = pes_path[-1] * (1 + daily_return + random_component / 2)
|
| 105 |
+
pes_path.append(new_price)
|
| 106 |
+
|
| 107 |
+
# 生成日期序列
|
| 108 |
+
start_date = datetime.now()
|
| 109 |
+
dates = [(start_date + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days + 1)]
|
| 110 |
+
|
| 111 |
+
# 组织结果
|
| 112 |
+
return {
|
| 113 |
+
'current_price': current_price,
|
| 114 |
+
'optimistic': {
|
| 115 |
+
'target_price': optimistic_target,
|
| 116 |
+
'change_percent': (optimistic_target / current_price - 1) * 100,
|
| 117 |
+
'path': dict(zip(dates, opt_path))
|
| 118 |
+
},
|
| 119 |
+
'neutral': {
|
| 120 |
+
'target_price': neutral_target,
|
| 121 |
+
'change_percent': (neutral_target / current_price - 1) * 100,
|
| 122 |
+
'path': dict(zip(dates, neu_path))
|
| 123 |
+
},
|
| 124 |
+
'pessimistic': {
|
| 125 |
+
'target_price': pessimistic_target,
|
| 126 |
+
'change_percent': (pessimistic_target / current_price - 1) * 100,
|
| 127 |
+
'path': dict(zip(dates, pes_path))
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
def _generate_ai_analysis(self, stock_code, stock_info, df, scenarios):
|
| 132 |
+
"""使用AI生成各情景的分析说明"""
|
| 133 |
+
try:
|
| 134 |
+
openai.api_key = self.openai_api_key
|
| 135 |
+
openai.api_base = self.openai_api_url
|
| 136 |
+
|
| 137 |
+
# 提取关键数据
|
| 138 |
+
current_price = df.iloc[-1]['close']
|
| 139 |
+
ma5 = df.iloc[-1]['MA5']
|
| 140 |
+
ma20 = df.iloc[-1]['MA20']
|
| 141 |
+
ma60 = df.iloc[-1]['MA60']
|
| 142 |
+
rsi = df.iloc[-1]['RSI']
|
| 143 |
+
macd = df.iloc[-1]['MACD']
|
| 144 |
+
signal = df.iloc[-1]['Signal']
|
| 145 |
+
|
| 146 |
+
# 构建提示词
|
| 147 |
+
prompt = f"""分析股票{stock_code}({stock_info.get('股票名称', '未知')})的三种市场情景:
|
| 148 |
+
|
| 149 |
+
1. 当前数据:
|
| 150 |
+
- 当前价格: {current_price}
|
| 151 |
+
- 均线: MA5={ma5}, MA20={ma20}, MA60={ma60}
|
| 152 |
+
- RSI: {rsi}
|
| 153 |
+
- MACD: {macd}, Signal: {signal}
|
| 154 |
+
|
| 155 |
+
2. 预测目标价:
|
| 156 |
+
- 乐观情景: {scenarios['optimistic']['target_price']:.2f} ({scenarios['optimistic']['change_percent']:.2f}%)
|
| 157 |
+
- 中性情景: {scenarios['neutral']['target_price']:.2f} ({scenarios['neutral']['change_percent']:.2f}%)
|
| 158 |
+
- 悲观情景: {scenarios['pessimistic']['target_price']:.2f} ({scenarios['pessimistic']['change_percent']:.2f}%)
|
| 159 |
+
|
| 160 |
+
请为每种情景提供简短分析(每种情景100字以内),包括可能的触发条件和风险因素。格式为JSON:
|
| 161 |
+
{{
|
| 162 |
+
"optimistic_analysis": "乐观情景分析...",
|
| 163 |
+
"neutral_analysis": "中性情景分析...",
|
| 164 |
+
"pessimistic_analysis": "悲观情景分析..."
|
| 165 |
+
}}
|
| 166 |
+
"""
|
| 167 |
+
|
| 168 |
+
# 调用AI API
|
| 169 |
+
response = openai.ChatCompletion.create(
|
| 170 |
+
model=self.openai_model,
|
| 171 |
+
messages=[
|
| 172 |
+
{"role": "system", "content": "你是专业的股票分析师,擅长技术分析和情景预测。"},
|
| 173 |
+
{"role": "user", "content": prompt}
|
| 174 |
+
],
|
| 175 |
+
temperature=0.7
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# 解析AI回复
|
| 179 |
+
import json
|
| 180 |
+
try:
|
| 181 |
+
analysis = json.loads(response.choices[0].message.content)
|
| 182 |
+
return analysis
|
| 183 |
+
except:
|
| 184 |
+
# 如果解析失败,尝试从文本中提取JSON
|
| 185 |
+
import re
|
| 186 |
+
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response.choices[0].message.content)
|
| 187 |
+
if json_match:
|
| 188 |
+
json_str = json_match.group(1)
|
| 189 |
+
return json.loads(json_str)
|
| 190 |
+
else:
|
| 191 |
+
return {
|
| 192 |
+
"optimistic_analysis": "乐观情景分析暂无",
|
| 193 |
+
"neutral_analysis": "中性情景分析暂无",
|
| 194 |
+
"pessimistic_analysis": "悲观情景分析暂无"
|
| 195 |
+
}
|
| 196 |
+
except Exception as e:
|
| 197 |
+
print(f"生成AI分析出错: {str(e)}")
|
| 198 |
+
return {
|
| 199 |
+
"optimistic_analysis": "乐观情景分析暂无",
|
| 200 |
+
"neutral_analysis": "中性情景分析暂无",
|
| 201 |
+
"pessimistic_analysis": "悲观情景分析暂无"
|
| 202 |
+
}
|
start.sh
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# 智能分析系统管理脚本
|
| 4 |
+
# 功能:启动、停止、重启和监控系统服务
|
| 5 |
+
|
| 6 |
+
# 配置
|
| 7 |
+
APP_NAME="智能分析系统"
|
| 8 |
+
APP_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
| 9 |
+
PYTHON_CMD="python"
|
| 10 |
+
SERVER_SCRIPT="web_server.py"
|
| 11 |
+
PID_FILE="${APP_DIR}/.server.pid"
|
| 12 |
+
LOG_FILE="${APP_DIR}/server.log"
|
| 13 |
+
MONITOR_INTERVAL=30 # 监控检查间隔(秒)
|
| 14 |
+
|
| 15 |
+
# 颜色配置
|
| 16 |
+
RED='\033[0;31m'
|
| 17 |
+
GREEN='\033[0;32m'
|
| 18 |
+
YELLOW='\033[0;33m'
|
| 19 |
+
BLUE='\033[0;34m'
|
| 20 |
+
NC='\033[0m' # 无颜色
|
| 21 |
+
|
| 22 |
+
# 函数:显示帮助信息
|
| 23 |
+
show_help() {
|
| 24 |
+
echo -e "${BLUE}${APP_NAME}管理脚本${NC}"
|
| 25 |
+
echo "使用方法: $0 [命令]"
|
| 26 |
+
echo ""
|
| 27 |
+
echo "命令:"
|
| 28 |
+
echo " start 启动服务"
|
| 29 |
+
echo " stop 停止服务"
|
| 30 |
+
echo " restart 重启服务"
|
| 31 |
+
echo " status 查看服务状态"
|
| 32 |
+
echo " monitor 以守护进程方式监控服务"
|
| 33 |
+
echo " logs 查看日志"
|
| 34 |
+
echo " help 显示此帮助信息"
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# 函数:检查前置条件
|
| 38 |
+
check_prerequisites() {
|
| 39 |
+
# 检查Python是否已安装
|
| 40 |
+
if ! command -v $PYTHON_CMD &> /dev/null; then
|
| 41 |
+
echo -e "${RED}错误: 未找到Python命令。请确保Python已安装且在PATH中。${NC}"
|
| 42 |
+
exit 1
|
| 43 |
+
fi
|
| 44 |
+
|
| 45 |
+
# 检查server脚本是否存在
|
| 46 |
+
if [ ! -f "${APP_DIR}/${SERVER_SCRIPT}" ]; then
|
| 47 |
+
echo -e "${RED}错误: 未找到服务器脚本 ${SERVER_SCRIPT}。${NC}"
|
| 48 |
+
echo -e "${YELLOW}当前目录: $(pwd)${NC}"
|
| 49 |
+
exit 1
|
| 50 |
+
fi
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# 函数:获取进程ID
|
| 54 |
+
get_pid() {
|
| 55 |
+
if [ -f "$PID_FILE" ]; then
|
| 56 |
+
local pid=$(cat "$PID_FILE")
|
| 57 |
+
if ps -p $pid > /dev/null; then
|
| 58 |
+
echo $pid
|
| 59 |
+
return 0
|
| 60 |
+
fi
|
| 61 |
+
fi
|
| 62 |
+
# 尝试通过进程名查找
|
| 63 |
+
local pid=$(pgrep -f "python.*${SERVER_SCRIPT}" 2>/dev/null)
|
| 64 |
+
if [ -n "$pid" ]; then
|
| 65 |
+
echo $pid
|
| 66 |
+
return 0
|
| 67 |
+
fi
|
| 68 |
+
echo ""
|
| 69 |
+
return 1
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# 函数:启动服务
|
| 73 |
+
start_server() {
|
| 74 |
+
local pid=$(get_pid)
|
| 75 |
+
if [ -n "$pid" ]; then
|
| 76 |
+
echo -e "${YELLOW}警告: 服务已在运行 (PID: $pid)${NC}"
|
| 77 |
+
return 0
|
| 78 |
+
fi
|
| 79 |
+
|
| 80 |
+
echo -e "${BLUE}正在启动${APP_NAME}...${NC}"
|
| 81 |
+
cd "$APP_DIR"
|
| 82 |
+
|
| 83 |
+
# 使用nohup在后台启动服务
|
| 84 |
+
nohup $PYTHON_CMD $SERVER_SCRIPT > "$LOG_FILE" 2>&1 &
|
| 85 |
+
local new_pid=$!
|
| 86 |
+
|
| 87 |
+
# 保存PID到文件
|
| 88 |
+
echo $new_pid > "$PID_FILE"
|
| 89 |
+
|
| 90 |
+
# 等待几秒检查服务是否成功启动
|
| 91 |
+
sleep 3
|
| 92 |
+
if ps -p $new_pid > /dev/null; then
|
| 93 |
+
echo -e "${GREEN}${APP_NAME}已成功启动 (PID: $new_pid)${NC}"
|
| 94 |
+
return 0
|
| 95 |
+
else
|
| 96 |
+
echo -e "${RED}启动${APP_NAME}失败。查看日志获取更多信息: ${LOG_FILE}${NC}"
|
| 97 |
+
return 1
|
| 98 |
+
fi
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# 函数:停止服务
|
| 102 |
+
stop_server() {
|
| 103 |
+
local pid=$(get_pid)
|
| 104 |
+
if [ -z "$pid" ]; then
|
| 105 |
+
echo -e "${YELLOW}服务未运行${NC}"
|
| 106 |
+
return 0
|
| 107 |
+
fi
|
| 108 |
+
|
| 109 |
+
echo -e "${BLUE}正在停止${APP_NAME} (PID: $pid)...${NC}"
|
| 110 |
+
|
| 111 |
+
# 尝试优雅地停止服务
|
| 112 |
+
kill -15 $pid
|
| 113 |
+
|
| 114 |
+
# 等待服务停止
|
| 115 |
+
local max_wait=10
|
| 116 |
+
local waited=0
|
| 117 |
+
while ps -p $pid > /dev/null && [ $waited -lt $max_wait ]; do
|
| 118 |
+
sleep 1
|
| 119 |
+
waited=$((waited + 1))
|
| 120 |
+
echo -ne "${YELLOW}等待服务停止 $waited/$max_wait ${NC}\r"
|
| 121 |
+
done
|
| 122 |
+
echo ""
|
| 123 |
+
|
| 124 |
+
# 如果服务仍在运行,强制停止
|
| 125 |
+
if ps -p $pid > /dev/null; then
|
| 126 |
+
echo -e "${YELLOW}服务未响应优雅停止请求,正在强制终止...${NC}"
|
| 127 |
+
kill -9 $pid
|
| 128 |
+
sleep 1
|
| 129 |
+
fi
|
| 130 |
+
|
| 131 |
+
# 检查服务是否已停止
|
| 132 |
+
if ps -p $pid > /dev/null; then
|
| 133 |
+
echo -e "${RED}无法停止服务 (PID: $pid)${NC}"
|
| 134 |
+
return 1
|
| 135 |
+
else
|
| 136 |
+
echo -e "${GREEN}服务已成功停止${NC}"
|
| 137 |
+
rm -f "$PID_FILE"
|
| 138 |
+
return 0
|
| 139 |
+
fi
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# 函数:重启服务
|
| 143 |
+
restart_server() {
|
| 144 |
+
echo -e "${BLUE}正在重启${APP_NAME}...${NC}"
|
| 145 |
+
stop_server
|
| 146 |
+
sleep 2
|
| 147 |
+
start_server
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
# 函数:检查服务状态
|
| 151 |
+
check_status() {
|
| 152 |
+
local pid=$(get_pid)
|
| 153 |
+
if [ -n "$pid" ]; then
|
| 154 |
+
local uptime=$(ps -o etime= -p $pid)
|
| 155 |
+
local mem=$(ps -o %mem= -p $pid)
|
| 156 |
+
local cpu=$(ps -o %cpu= -p $pid)
|
| 157 |
+
|
| 158 |
+
echo -e "${GREEN}${APP_NAME}正在运行${NC}"
|
| 159 |
+
echo -e " PID: ${BLUE}$pid${NC}"
|
| 160 |
+
echo -e " 运行时间: ${BLUE}$uptime${NC}"
|
| 161 |
+
echo -e " 内存使用: ${BLUE}$mem%${NC}"
|
| 162 |
+
echo -e " CPU使用: ${BLUE}$cpu%${NC}"
|
| 163 |
+
echo -e " 日志文件: ${BLUE}$LOG_FILE${NC}"
|
| 164 |
+
return 0
|
| 165 |
+
else
|
| 166 |
+
echo -e "${YELLOW}${APP_NAME}未运行${NC}"
|
| 167 |
+
return 1
|
| 168 |
+
fi
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
# 函数:监控服务
|
| 172 |
+
monitor_server() {
|
| 173 |
+
echo -e "${BLUE}开始监控${APP_NAME}...${NC}"
|
| 174 |
+
echo -e "${BLUE}监控日志将写入: ${LOG_FILE}.monitor${NC}"
|
| 175 |
+
echo -e "${YELLOW}按 Ctrl+C 停止监控${NC}"
|
| 176 |
+
|
| 177 |
+
# 在后台启动监控
|
| 178 |
+
(
|
| 179 |
+
while true; do
|
| 180 |
+
local pid=$(get_pid)
|
| 181 |
+
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
| 182 |
+
|
| 183 |
+
if [ -z "$pid" ]; then
|
| 184 |
+
echo "$timestamp - 服务未运行,正在重启..." >> "${LOG_FILE}.monitor"
|
| 185 |
+
cd "$APP_DIR"
|
| 186 |
+
$PYTHON_CMD $SERVER_SCRIPT >> "$LOG_FILE" 2>&1 &
|
| 187 |
+
local new_pid=$!
|
| 188 |
+
echo $new_pid > "$PID_FILE"
|
| 189 |
+
echo "$timestamp - 服务已重启 (PID: $new_pid)" >> "${LOG_FILE}.monitor"
|
| 190 |
+
else
|
| 191 |
+
# 检查服务是否响应 (可以通过访问服务API实现)
|
| 192 |
+
local is_responsive=true
|
| 193 |
+
|
| 194 |
+
# 这里可以添加额外的健康检查逻辑
|
| 195 |
+
# 例如:使用curl检查API是否响应
|
| 196 |
+
# if ! curl -s http://localhost:8888/health > /dev/null; then
|
| 197 |
+
# is_responsive=false
|
| 198 |
+
# fi
|
| 199 |
+
|
| 200 |
+
if [ "$is_responsive" = false ]; then
|
| 201 |
+
echo "$timestamp - 服务无响应,正在重启..." >> "${LOG_FILE}.monitor"
|
| 202 |
+
kill -9 $pid
|
| 203 |
+
sleep 2
|
| 204 |
+
cd "$APP_DIR"
|
| 205 |
+
$PYTHON_CMD $SERVER_SCRIPT >> "$LOG_FILE" 2>&1 &
|
| 206 |
+
local new_pid=$!
|
| 207 |
+
echo $new_pid > "$PID_FILE"
|
| 208 |
+
echo "$timestamp - 服务已重启 (PID: $new_pid)" >> "${LOG_FILE}.monitor"
|
| 209 |
+
fi
|
| 210 |
+
fi
|
| 211 |
+
|
| 212 |
+
sleep $MONITOR_INTERVAL
|
| 213 |
+
done
|
| 214 |
+
) &
|
| 215 |
+
|
| 216 |
+
# 保存监控进程PID
|
| 217 |
+
MONITOR_PID=$!
|
| 218 |
+
echo $MONITOR_PID > "${APP_DIR}/.monitor.pid"
|
| 219 |
+
echo -e "${GREEN}监控进程已启动 (PID: $MONITOR_PID)${NC}"
|
| 220 |
+
|
| 221 |
+
# 捕获Ctrl+C以停止监控
|
| 222 |
+
trap 'kill $MONITOR_PID; echo -e "${YELLOW}监控已停止${NC}"; rm -f "${APP_DIR}/.monitor.pid"' INT
|
| 223 |
+
|
| 224 |
+
# 等待监控进程
|
| 225 |
+
wait $MONITOR_PID
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
# 函数:查看日志
|
| 229 |
+
view_logs() {
|
| 230 |
+
if [ ! -f "$LOG_FILE" ]; then
|
| 231 |
+
echo -e "${YELLOW}日志文件不存在: ${LOG_FILE}${NC}"
|
| 232 |
+
return 1
|
| 233 |
+
fi
|
| 234 |
+
|
| 235 |
+
echo -e "${BLUE}显示最新的日志内容 (按Ctrl+C退出)${NC}"
|
| 236 |
+
tail -f "$LOG_FILE"
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
# 主函数
|
| 240 |
+
main() {
|
| 241 |
+
check_prerequisites
|
| 242 |
+
|
| 243 |
+
local command=${1:-"help"}
|
| 244 |
+
|
| 245 |
+
case $command in
|
| 246 |
+
start)
|
| 247 |
+
start_server
|
| 248 |
+
;;
|
| 249 |
+
stop)
|
| 250 |
+
stop_server
|
| 251 |
+
;;
|
| 252 |
+
restart)
|
| 253 |
+
restart_server
|
| 254 |
+
;;
|
| 255 |
+
status)
|
| 256 |
+
check_status
|
| 257 |
+
;;
|
| 258 |
+
monitor)
|
| 259 |
+
monitor_server
|
| 260 |
+
;;
|
| 261 |
+
logs)
|
| 262 |
+
view_logs
|
| 263 |
+
;;
|
| 264 |
+
*)
|
| 265 |
+
show_help
|
| 266 |
+
;;
|
| 267 |
+
esac
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# 执行主函数
|
| 271 |
+
main "$@"
|
static/favicon.ico
ADDED
|
|
Git LFS Details
|
static/swagger.json
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"swagger": "2.0",
|
| 3 |
+
"info": {
|
| 4 |
+
"title": "股票智能分析系统 API",
|
| 5 |
+
"description": "股票智能分析系统的REST API文档",
|
| 6 |
+
"version": "2.1.0"
|
| 7 |
+
},
|
| 8 |
+
"host": "localhost:8888",
|
| 9 |
+
"basePath": "/",
|
| 10 |
+
"schemes": ["http", "https"],
|
| 11 |
+
"paths": {
|
| 12 |
+
"/analyze": {
|
| 13 |
+
"post": {
|
| 14 |
+
"summary": "分析股票",
|
| 15 |
+
"description": "分析单只或多只股票",
|
| 16 |
+
"parameters": [
|
| 17 |
+
{
|
| 18 |
+
"name": "body",
|
| 19 |
+
"in": "body",
|
| 20 |
+
"required": true,
|
| 21 |
+
"schema": {
|
| 22 |
+
"type": "object",
|
| 23 |
+
"properties": {
|
| 24 |
+
"stock_codes": {
|
| 25 |
+
"type": "array",
|
| 26 |
+
"items": {
|
| 27 |
+
"type": "string"
|
| 28 |
+
},
|
| 29 |
+
"example": ["600519", "000858"]
|
| 30 |
+
},
|
| 31 |
+
"market_type": {
|
| 32 |
+
"type": "string",
|
| 33 |
+
"example": "A"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"responses": {
|
| 40 |
+
"200": {
|
| 41 |
+
"description": "成功分析股票"
|
| 42 |
+
},
|
| 43 |
+
"400": {
|
| 44 |
+
"description": "请求参数错误"
|
| 45 |
+
},
|
| 46 |
+
"500": {
|
| 47 |
+
"description": "服务器内部错误"
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
"/api/start_stock_analysis": {
|
| 53 |
+
"post": {
|
| 54 |
+
"summary": "启动股票分析任务",
|
| 55 |
+
"description": "启动异步股票分析任务",
|
| 56 |
+
"parameters": [
|
| 57 |
+
{
|
| 58 |
+
"name": "body",
|
| 59 |
+
"in": "body",
|
| 60 |
+
"required": true,
|
| 61 |
+
"schema": {
|
| 62 |
+
"type": "object",
|
| 63 |
+
"properties": {
|
| 64 |
+
"stock_code": {
|
| 65 |
+
"type": "string",
|
| 66 |
+
"example": "600519"
|
| 67 |
+
},
|
| 68 |
+
"market_type": {
|
| 69 |
+
"type": "string",
|
| 70 |
+
"example": "A"
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
],
|
| 76 |
+
"responses": {
|
| 77 |
+
"200": {
|
| 78 |
+
"description": "成功启动分析任务"
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
"/api/analysis_status/{task_id}": {
|
| 84 |
+
"get": {
|
| 85 |
+
"summary": "获取分析任务状态",
|
| 86 |
+
"description": "获取异步分析任务的状态和结果",
|
| 87 |
+
"parameters": [
|
| 88 |
+
{
|
| 89 |
+
"name": "task_id",
|
| 90 |
+
"in": "path",
|
| 91 |
+
"required": true,
|
| 92 |
+
"type": "string"
|
| 93 |
+
}
|
| 94 |
+
],
|
| 95 |
+
"responses": {
|
| 96 |
+
"200": {
|
| 97 |
+
"description": "成功获取任务状态和结果"
|
| 98 |
+
},
|
| 99 |
+
"404": {
|
| 100 |
+
"description": "找不到指定的任务"
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
"/api/stock_data": {
|
| 106 |
+
"get": {
|
| 107 |
+
"summary": "获取股票数据",
|
| 108 |
+
"description": "获取股票历史数据和技术指标",
|
| 109 |
+
"parameters": [
|
| 110 |
+
{
|
| 111 |
+
"name": "stock_code",
|
| 112 |
+
"in": "query",
|
| 113 |
+
"required": true,
|
| 114 |
+
"type": "string"
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"name": "market_type",
|
| 118 |
+
"in": "query",
|
| 119 |
+
"required": false,
|
| 120 |
+
"type": "string",
|
| 121 |
+
"default": "A"
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"name": "period",
|
| 125 |
+
"in": "query",
|
| 126 |
+
"required": false,
|
| 127 |
+
"type": "string",
|
| 128 |
+
"enum": ["1m", "3m", "6m", "1y"],
|
| 129 |
+
"default": "1y"
|
| 130 |
+
}
|
| 131 |
+
],
|
| 132 |
+
"responses": {
|
| 133 |
+
"200": {
|
| 134 |
+
"description": "成功获取股票数据"
|
| 135 |
+
},
|
| 136 |
+
"400": {
|
| 137 |
+
"description": "请求参数错误"
|
| 138 |
+
},
|
| 139 |
+
"500": {
|
| 140 |
+
"description": "服务器内部错误"
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
},
|
| 145 |
+
"/api/start_market_scan": {
|
| 146 |
+
"post": {
|
| 147 |
+
"summary": "启动市场扫描任务",
|
| 148 |
+
"description": "启动异步市场扫描任务",
|
| 149 |
+
"parameters": [
|
| 150 |
+
{
|
| 151 |
+
"name": "body",
|
| 152 |
+
"in": "body",
|
| 153 |
+
"required": true,
|
| 154 |
+
"schema": {
|
| 155 |
+
"type": "object",
|
| 156 |
+
"properties": {
|
| 157 |
+
"stock_list": {
|
| 158 |
+
"type": "array",
|
| 159 |
+
"items": {
|
| 160 |
+
"type": "string"
|
| 161 |
+
},
|
| 162 |
+
"example": ["600519", "000858"]
|
| 163 |
+
},
|
| 164 |
+
"min_score": {
|
| 165 |
+
"type": "integer",
|
| 166 |
+
"example": 60
|
| 167 |
+
},
|
| 168 |
+
"market_type": {
|
| 169 |
+
"type": "string",
|
| 170 |
+
"example": "A"
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
],
|
| 176 |
+
"responses": {
|
| 177 |
+
"200": {
|
| 178 |
+
"description": "成功启动扫描任务"
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
},
|
| 183 |
+
"/api/scan_status/{task_id}": {
|
| 184 |
+
"get": {
|
| 185 |
+
"summary": "���取扫描任务状态",
|
| 186 |
+
"description": "获取异步扫描任务的状态和结果",
|
| 187 |
+
"parameters": [
|
| 188 |
+
{
|
| 189 |
+
"name": "task_id",
|
| 190 |
+
"in": "path",
|
| 191 |
+
"required": true,
|
| 192 |
+
"type": "string"
|
| 193 |
+
}
|
| 194 |
+
],
|
| 195 |
+
"responses": {
|
| 196 |
+
"200": {
|
| 197 |
+
"description": "成功获取任务状态和结果"
|
| 198 |
+
},
|
| 199 |
+
"404": {
|
| 200 |
+
"description": "找不到指定的任务"
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
},
|
| 205 |
+
"/api/index_stocks": {
|
| 206 |
+
"get": {
|
| 207 |
+
"summary": "获取指数成分股",
|
| 208 |
+
"description": "获取指定指数的成分股列表",
|
| 209 |
+
"parameters": [
|
| 210 |
+
{
|
| 211 |
+
"name": "index_code",
|
| 212 |
+
"in": "query",
|
| 213 |
+
"required": true,
|
| 214 |
+
"type": "string",
|
| 215 |
+
"example": "000300"
|
| 216 |
+
}
|
| 217 |
+
],
|
| 218 |
+
"responses": {
|
| 219 |
+
"200": {
|
| 220 |
+
"description": "成功获取指数成分股"
|
| 221 |
+
},
|
| 222 |
+
"400": {
|
| 223 |
+
"description": "请求参数错误"
|
| 224 |
+
},
|
| 225 |
+
"500": {
|
| 226 |
+
"description": "服务器内部错误"
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
},
|
| 231 |
+
"/api/industry_stocks": {
|
| 232 |
+
"get": {
|
| 233 |
+
"summary": "获取行业成分股",
|
| 234 |
+
"description": "获取指定行业的成分股列表",
|
| 235 |
+
"parameters": [
|
| 236 |
+
{
|
| 237 |
+
"name": "industry",
|
| 238 |
+
"in": "query",
|
| 239 |
+
"required": true,
|
| 240 |
+
"type": "string",
|
| 241 |
+
"example": "银行"
|
| 242 |
+
}
|
| 243 |
+
],
|
| 244 |
+
"responses": {
|
| 245 |
+
"200": {
|
| 246 |
+
"description": "成功获取行业成分股"
|
| 247 |
+
},
|
| 248 |
+
"400": {
|
| 249 |
+
"description": "请求参数错误"
|
| 250 |
+
},
|
| 251 |
+
"500": {
|
| 252 |
+
"description": "服务器内部错误"
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
},
|
| 257 |
+
"/api/fundamental_analysis": {
|
| 258 |
+
"post": {
|
| 259 |
+
"summary": "基本面分析",
|
| 260 |
+
"description": "获取股票的基本面分析结果",
|
| 261 |
+
"parameters": [
|
| 262 |
+
{
|
| 263 |
+
"name": "body",
|
| 264 |
+
"in": "body",
|
| 265 |
+
"required": true,
|
| 266 |
+
"schema": {
|
| 267 |
+
"type": "object",
|
| 268 |
+
"properties": {
|
| 269 |
+
"stock_code": {
|
| 270 |
+
"type": "string",
|
| 271 |
+
"example": "600519"
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
],
|
| 277 |
+
"responses": {
|
| 278 |
+
"200": {
|
| 279 |
+
"description": "成功获取基本面分析结果"
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
"/api/capital_flow": {
|
| 285 |
+
"post": {
|
| 286 |
+
"summary": "资金流向分析",
|
| 287 |
+
"description": "获取股票的资金流向分析结果",
|
| 288 |
+
"parameters": [
|
| 289 |
+
{
|
| 290 |
+
"name": "body",
|
| 291 |
+
"in": "body",
|
| 292 |
+
"required": true,
|
| 293 |
+
"schema": {
|
| 294 |
+
"type": "object",
|
| 295 |
+
"properties": {
|
| 296 |
+
"stock_code": {
|
| 297 |
+
"type": "string",
|
| 298 |
+
"example": "600519"
|
| 299 |
+
},
|
| 300 |
+
"days": {
|
| 301 |
+
"type": "integer",
|
| 302 |
+
"example": 10
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
],
|
| 308 |
+
"responses": {
|
| 309 |
+
"200": {
|
| 310 |
+
"description": "成功获取资金流向分析结果"
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
},
|
| 315 |
+
"/api/scenario_predict": {
|
| 316 |
+
"post": {
|
| 317 |
+
"summary": "情景预测",
|
| 318 |
+
"description": "获取股票的多情景预测结果",
|
| 319 |
+
"parameters": [
|
| 320 |
+
{
|
| 321 |
+
"name": "body",
|
| 322 |
+
"in": "body",
|
| 323 |
+
"required": true,
|
| 324 |
+
"schema": {
|
| 325 |
+
"type": "object",
|
| 326 |
+
"properties": {
|
| 327 |
+
"stock_code": {
|
| 328 |
+
"type": "string",
|
| 329 |
+
"example": "600519"
|
| 330 |
+
},
|
| 331 |
+
"market_type": {
|
| 332 |
+
"type": "string",
|
| 333 |
+
"example": "A"
|
| 334 |
+
},
|
| 335 |
+
"days": {
|
| 336 |
+
"type": "integer",
|
| 337 |
+
"example": 60
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
],
|
| 343 |
+
"responses": {
|
| 344 |
+
"200": {
|
| 345 |
+
"description": "成功获取情景预测结果"
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
},
|
| 350 |
+
"/api/qa": {
|
| 351 |
+
"post": {
|
| 352 |
+
"summary": "智能问答",
|
| 353 |
+
"description": "获取股票相关问题的智能回答",
|
| 354 |
+
"parameters": [
|
| 355 |
+
{
|
| 356 |
+
"name": "body",
|
| 357 |
+
"in": "body",
|
| 358 |
+
"required": true,
|
| 359 |
+
"schema": {
|
| 360 |
+
"type": "object",
|
| 361 |
+
"properties": {
|
| 362 |
+
"stock_code": {
|
| 363 |
+
"type": "string",
|
| 364 |
+
"example": "600519"
|
| 365 |
+
},
|
| 366 |
+
"question": {
|
| 367 |
+
"type": "string",
|
| 368 |
+
"example": "这只股票的主要支撑位是多少?"
|
| 369 |
+
},
|
| 370 |
+
"market_type": {
|
| 371 |
+
"type": "string",
|
| 372 |
+
"example": "A"
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
],
|
| 378 |
+
"responses": {
|
| 379 |
+
"200": {
|
| 380 |
+
"description": "成功获取智能回答"
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
},
|
| 385 |
+
"/api/risk_analysis": {
|
| 386 |
+
"post": {
|
| 387 |
+
"summary": "风险分析",
|
| 388 |
+
"description": "获取股票的风险分析结果",
|
| 389 |
+
"parameters": [
|
| 390 |
+
{
|
| 391 |
+
"name": "body",
|
| 392 |
+
"in": "body",
|
| 393 |
+
"required": true,
|
| 394 |
+
"schema": {
|
| 395 |
+
"type": "object",
|
| 396 |
+
"properties": {
|
| 397 |
+
"stock_code": {
|
| 398 |
+
"type": "string",
|
| 399 |
+
"example": "600519"
|
| 400 |
+
},
|
| 401 |
+
"market_type": {
|
| 402 |
+
"type": "string",
|
| 403 |
+
"example": "A"
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
}
|
| 408 |
+
],
|
| 409 |
+
"responses": {
|
| 410 |
+
"200": {
|
| 411 |
+
"description": "成功获取风险分析结果"
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
},
|
| 416 |
+
"/api/portfolio_risk": {
|
| 417 |
+
"post": {
|
| 418 |
+
"summary": "投资组合风险分析",
|
| 419 |
+
"description": "获取投资组合的整体风险分析结果",
|
| 420 |
+
"parameters": [
|
| 421 |
+
{
|
| 422 |
+
"name": "body",
|
| 423 |
+
"in": "body",
|
| 424 |
+
"required": true,
|
| 425 |
+
"schema": {
|
| 426 |
+
"type": "object",
|
| 427 |
+
"properties": {
|
| 428 |
+
"portfolio": {
|
| 429 |
+
"type": "array",
|
| 430 |
+
"items": {
|
| 431 |
+
"type": "object",
|
| 432 |
+
"properties": {
|
| 433 |
+
"stock_code": {
|
| 434 |
+
"type": "string"
|
| 435 |
+
},
|
| 436 |
+
"weight": {
|
| 437 |
+
"type": "number"
|
| 438 |
+
},
|
| 439 |
+
"market_type": {
|
| 440 |
+
"type": "string"
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
},
|
| 444 |
+
"example": [
|
| 445 |
+
{
|
| 446 |
+
"stock_code": "600519",
|
| 447 |
+
"weight": 30,
|
| 448 |
+
"market_type": "A"
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"stock_code": "000858",
|
| 452 |
+
"weight": 20,
|
| 453 |
+
"market_type": "A"
|
| 454 |
+
}
|
| 455 |
+
]
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
],
|
| 461 |
+
"responses": {
|
| 462 |
+
"200": {
|
| 463 |
+
"description": "成功获取投资组合风险分析结果"
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
},
|
| 468 |
+
"/api/index_analysis": {
|
| 469 |
+
"get": {
|
| 470 |
+
"summary": "指数分析",
|
| 471 |
+
"description": "获取指数的整体分析结果",
|
| 472 |
+
"parameters": [
|
| 473 |
+
{
|
| 474 |
+
"name": "index_code",
|
| 475 |
+
"in": "query",
|
| 476 |
+
"required": true,
|
| 477 |
+
"type": "string",
|
| 478 |
+
"example": "000300"
|
| 479 |
+
},
|
| 480 |
+
{
|
| 481 |
+
"name": "limit",
|
| 482 |
+
"in": "query",
|
| 483 |
+
"required": false,
|
| 484 |
+
"type": "integer",
|
| 485 |
+
"example": 30
|
| 486 |
+
}
|
| 487 |
+
],
|
| 488 |
+
"responses": {
|
| 489 |
+
"200": {
|
| 490 |
+
"description": "成功获取指数分析结果"
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
}
|
| 494 |
+
},
|
| 495 |
+
"/api/industry_analysis": {
|
| 496 |
+
"get": {
|
| 497 |
+
"summary": "行业分析",
|
| 498 |
+
"description": "获取行业的整体分析结果",
|
| 499 |
+
"parameters": [
|
| 500 |
+
{
|
| 501 |
+
"name": "industry",
|
| 502 |
+
"in": "query",
|
| 503 |
+
"required": true,
|
| 504 |
+
"type": "string",
|
| 505 |
+
"example": "银行"
|
| 506 |
+
},
|
| 507 |
+
{
|
| 508 |
+
"name": "limit",
|
| 509 |
+
"in": "query",
|
| 510 |
+
"required": false,
|
| 511 |
+
"type": "integer",
|
| 512 |
+
"example": 30
|
| 513 |
+
}
|
| 514 |
+
],
|
| 515 |
+
"responses": {
|
| 516 |
+
"200": {
|
| 517 |
+
"description": "成功获取行业分析结果"
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
},
|
| 522 |
+
"/api/industry_compare": {
|
| 523 |
+
"get": {
|
| 524 |
+
"summary": "行业比较",
|
| 525 |
+
"description": "比较不同行业的表现",
|
| 526 |
+
"parameters": [
|
| 527 |
+
{
|
| 528 |
+
"name": "limit",
|
| 529 |
+
"in": "query",
|
| 530 |
+
"required": false,
|
| 531 |
+
"type": "integer",
|
| 532 |
+
"example": 10
|
| 533 |
+
}
|
| 534 |
+
],
|
| 535 |
+
"responses": {
|
| 536 |
+
"200": {
|
| 537 |
+
"description": "成功获取行业比较结果"
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
}
|
| 542 |
+
},
|
| 543 |
+
"definitions": {
|
| 544 |
+
"Stock": {
|
| 545 |
+
"type": "object",
|
| 546 |
+
"properties": {
|
| 547 |
+
"stock_code": {
|
| 548 |
+
"type": "string"
|
| 549 |
+
},
|
| 550 |
+
"stock_name": {
|
| 551 |
+
"type": "string"
|
| 552 |
+
},
|
| 553 |
+
"price": {
|
| 554 |
+
"type": "number"
|
| 555 |
+
},
|
| 556 |
+
"price_change": {
|
| 557 |
+
"type": "number"
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
},
|
| 561 |
+
"AnalysisResult": {
|
| 562 |
+
"type": "object",
|
| 563 |
+
"properties": {
|
| 564 |
+
"score": {
|
| 565 |
+
"type": "number"
|
| 566 |
+
},
|
| 567 |
+
"recommendation": {
|
| 568 |
+
"type": "string"
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
+
}
|
| 573 |
+
}
|
stock_analyzer.py
ADDED
|
@@ -0,0 +1,2131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
修改:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# stock_analyzer.py
|
| 9 |
+
import time
|
| 10 |
+
import traceback
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
import os
|
| 15 |
+
import requests
|
| 16 |
+
from typing import Dict, List, Optional, Tuple
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
import logging
|
| 19 |
+
import math
|
| 20 |
+
import json
|
| 21 |
+
import threading
|
| 22 |
+
|
| 23 |
+
# 线程局部存储
|
| 24 |
+
thread_local = threading.local()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class StockAnalyzer:
|
| 28 |
+
"""
|
| 29 |
+
股票分析器 - 原有API保持不变,内部实现增强
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, initial_cash=1000000):
|
| 33 |
+
# 设置日志
|
| 34 |
+
logging.basicConfig(level=logging.INFO,
|
| 35 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
| 36 |
+
self.logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
# 加载环境变量
|
| 39 |
+
load_dotenv()
|
| 40 |
+
|
| 41 |
+
# 设置 OpenAI API (原 Gemini API)
|
| 42 |
+
self.openai_api_key = os.getenv('OPENAI_API_KEY', os.getenv('OPENAI_API_KEY'))
|
| 43 |
+
self.openai_api_url = os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1')
|
| 44 |
+
self.openai_model = os.getenv('OPENAI_API_MODEL', 'gemini-2.0-pro-exp-02-05')
|
| 45 |
+
self.news_model = os.getenv('NEWS_MODEL')
|
| 46 |
+
|
| 47 |
+
# 配置参数
|
| 48 |
+
self.params = {
|
| 49 |
+
'ma_periods': {'short': 5, 'medium': 20, 'long': 60},
|
| 50 |
+
'rsi_period': 14,
|
| 51 |
+
'bollinger_period': 20,
|
| 52 |
+
'bollinger_std': 2,
|
| 53 |
+
'volume_ma_period': 20,
|
| 54 |
+
'atr_period': 14
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# 添加缓存初始化
|
| 58 |
+
self.data_cache = {}
|
| 59 |
+
|
| 60 |
+
# JSON匹配标志
|
| 61 |
+
self.json_match_flag = True
|
| 62 |
+
def get_stock_data(self, stock_code, market_type='A', start_date=None, end_date=None):
|
| 63 |
+
"""获取股票数据"""
|
| 64 |
+
import akshare as ak
|
| 65 |
+
|
| 66 |
+
self.logger.info(f"开始获取股票 {stock_code} 数据,市场类型: {market_type}")
|
| 67 |
+
|
| 68 |
+
cache_key = f"{stock_code}_{market_type}_{start_date}_{end_date}_price"
|
| 69 |
+
if cache_key in self.data_cache:
|
| 70 |
+
cached_df = self.data_cache[cache_key]
|
| 71 |
+
# 创建一个副本以避免修改缓存数据
|
| 72 |
+
# 并确保副本的日期类型为datetime
|
| 73 |
+
result = cached_df.copy()
|
| 74 |
+
# If 'date' column exists but is not datetime, convert it
|
| 75 |
+
if 'date' in result.columns and not pd.api.types.is_datetime64_any_dtype(result['date']):
|
| 76 |
+
try:
|
| 77 |
+
result['date'] = pd.to_datetime(result['date'])
|
| 78 |
+
except Exception as e:
|
| 79 |
+
self.logger.warning(f"无法将日期列转换为datetime格式: {str(e)}")
|
| 80 |
+
return result
|
| 81 |
+
|
| 82 |
+
if start_date is None:
|
| 83 |
+
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
|
| 84 |
+
if end_date is None:
|
| 85 |
+
end_date = datetime.now().strftime('%Y%m%d')
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
# 根据市场类型获取数据
|
| 89 |
+
if market_type == 'A':
|
| 90 |
+
df = ak.stock_zh_a_hist(
|
| 91 |
+
symbol=stock_code,
|
| 92 |
+
start_date=start_date,
|
| 93 |
+
end_date=end_date,
|
| 94 |
+
adjust="qfq"
|
| 95 |
+
)
|
| 96 |
+
elif market_type == 'HK':
|
| 97 |
+
df = ak.stock_hk_daily(
|
| 98 |
+
symbol=stock_code,
|
| 99 |
+
adjust="qfq"
|
| 100 |
+
)
|
| 101 |
+
elif market_type == 'US':
|
| 102 |
+
df = ak.stock_us_hist(
|
| 103 |
+
symbol=stock_code,
|
| 104 |
+
start_date=start_date,
|
| 105 |
+
end_date=end_date,
|
| 106 |
+
adjust="qfq"
|
| 107 |
+
)
|
| 108 |
+
else:
|
| 109 |
+
raise ValueError(f"不支持的市场类型: {market_type}")
|
| 110 |
+
|
| 111 |
+
# 重命名列名以匹配分析需求
|
| 112 |
+
df = df.rename(columns={
|
| 113 |
+
"日期": "date",
|
| 114 |
+
"开盘": "open",
|
| 115 |
+
"收盘": "close",
|
| 116 |
+
"最高": "high",
|
| 117 |
+
"最低": "low",
|
| 118 |
+
"成交量": "volume",
|
| 119 |
+
"成交额": "amount"
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
# 确保日期格式正确
|
| 123 |
+
df['date'] = pd.to_datetime(df['date'])
|
| 124 |
+
|
| 125 |
+
# 数据类型转换
|
| 126 |
+
numeric_columns = ['open', 'close', 'high', 'low', 'volume']
|
| 127 |
+
for col in numeric_columns:
|
| 128 |
+
if col in df.columns:
|
| 129 |
+
df[col] = pd.to_numeric(df[col], errors='coerce')
|
| 130 |
+
|
| 131 |
+
# 删除空值
|
| 132 |
+
df = df.dropna()
|
| 133 |
+
|
| 134 |
+
result = df.sort_values('date')
|
| 135 |
+
|
| 136 |
+
# 缓存原始数据(包含datetime类型)
|
| 137 |
+
self.data_cache[cache_key] = result.copy()
|
| 138 |
+
|
| 139 |
+
return result
|
| 140 |
+
|
| 141 |
+
except Exception as e:
|
| 142 |
+
self.logger.error(f"获取股票数据失败: {e}")
|
| 143 |
+
raise Exception(f"获取股票数据失败: {e}")
|
| 144 |
+
|
| 145 |
+
def get_north_flow_history(self, stock_code, start_date=None, end_date=None):
|
| 146 |
+
"""获取单个股票的北向资金历史持股数据"""
|
| 147 |
+
try:
|
| 148 |
+
import akshare as ak
|
| 149 |
+
|
| 150 |
+
# 获取历史持股数据
|
| 151 |
+
if start_date is None and end_date is None:
|
| 152 |
+
# 默认获取近90天数据
|
| 153 |
+
north_hist_data = ak.stock_hsgt_hist_em(symbol=stock_code)
|
| 154 |
+
else:
|
| 155 |
+
north_hist_data = ak.stock_hsgt_hist_em(symbol=stock_code, start_date=start_date, end_date=end_date)
|
| 156 |
+
|
| 157 |
+
if north_hist_data.empty:
|
| 158 |
+
return {"history": []}
|
| 159 |
+
|
| 160 |
+
# 转换为列表格式返回
|
| 161 |
+
history = []
|
| 162 |
+
for _, row in north_hist_data.iterrows():
|
| 163 |
+
history.append({
|
| 164 |
+
"date": row.get('日期', ''),
|
| 165 |
+
"holding": float(row.get('持股数', 0)) if '持股数' in row else 0,
|
| 166 |
+
"ratio": float(row.get('持股比例', 0)) if '持股比例' in row else 0,
|
| 167 |
+
"change": float(row.get('持股变动', 0)) if '持股变动' in row else 0,
|
| 168 |
+
"market_value": float(row.get('持股市值', 0)) if '持股市值' in row else 0
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
return {"history": history}
|
| 172 |
+
except Exception as e:
|
| 173 |
+
self.logger.error(f"获取北向资金历史数据出错: {str(e)}")
|
| 174 |
+
return {"history": []}
|
| 175 |
+
|
| 176 |
+
def calculate_ema(self, series, period):
|
| 177 |
+
"""计算指数移动平均线"""
|
| 178 |
+
return series.ewm(span=period, adjust=False).mean()
|
| 179 |
+
|
| 180 |
+
def calculate_rsi(self, series, period):
|
| 181 |
+
"""计算RSI指标"""
|
| 182 |
+
delta = series.diff()
|
| 183 |
+
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
|
| 184 |
+
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
|
| 185 |
+
rs = gain / loss
|
| 186 |
+
return 100 - (100 / (1 + rs))
|
| 187 |
+
|
| 188 |
+
def calculate_macd(self, series):
|
| 189 |
+
"""计算MACD指标"""
|
| 190 |
+
exp1 = series.ewm(span=12, adjust=False).mean()
|
| 191 |
+
exp2 = series.ewm(span=26, adjust=False).mean()
|
| 192 |
+
macd = exp1 - exp2
|
| 193 |
+
signal = macd.ewm(span=9, adjust=False).mean()
|
| 194 |
+
hist = macd - signal
|
| 195 |
+
return macd, signal, hist
|
| 196 |
+
|
| 197 |
+
def calculate_bollinger_bands(self, series, period, std_dev):
|
| 198 |
+
"""计算布林带"""
|
| 199 |
+
middle = series.rolling(window=period).mean()
|
| 200 |
+
std = series.rolling(window=period).std()
|
| 201 |
+
upper = middle + (std * std_dev)
|
| 202 |
+
lower = middle - (std * std_dev)
|
| 203 |
+
return upper, middle, lower
|
| 204 |
+
|
| 205 |
+
def calculate_atr(self, df, period):
|
| 206 |
+
"""计算ATR指标"""
|
| 207 |
+
high = df['high']
|
| 208 |
+
low = df['low']
|
| 209 |
+
close = df['close'].shift(1)
|
| 210 |
+
|
| 211 |
+
tr1 = high - low
|
| 212 |
+
tr2 = abs(high - close)
|
| 213 |
+
tr3 = abs(low - close)
|
| 214 |
+
|
| 215 |
+
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
| 216 |
+
return tr.rolling(window=period).mean()
|
| 217 |
+
|
| 218 |
+
def format_indicator_data(self, df):
|
| 219 |
+
"""格式化指标数据,控制小数位数"""
|
| 220 |
+
|
| 221 |
+
# 格式化价格数据 (2位小数)
|
| 222 |
+
price_columns = ['open', 'close', 'high', 'low', 'MA5', 'MA20', 'MA60', 'BB_upper', 'BB_middle', 'BB_lower']
|
| 223 |
+
for col in price_columns:
|
| 224 |
+
if col in df.columns:
|
| 225 |
+
df[col] = df[col].round(2)
|
| 226 |
+
|
| 227 |
+
# 格式化MACD相关指标 (3位小数)
|
| 228 |
+
macd_columns = ['MACD', 'Signal', 'MACD_hist']
|
| 229 |
+
for col in macd_columns:
|
| 230 |
+
if col in df.columns:
|
| 231 |
+
df[col] = df[col].round(3)
|
| 232 |
+
|
| 233 |
+
# 格式化其他技术指标 (2位小数)
|
| 234 |
+
other_columns = ['RSI', 'Volatility', 'ROC', 'Volume_Ratio']
|
| 235 |
+
for col in other_columns:
|
| 236 |
+
if col in df.columns:
|
| 237 |
+
df[col] = df[col].round(2)
|
| 238 |
+
|
| 239 |
+
return df
|
| 240 |
+
|
| 241 |
+
def calculate_indicators(self, df):
|
| 242 |
+
"""计算技术指标"""
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
# 计算移动平均线
|
| 246 |
+
df['MA5'] = self.calculate_ema(df['close'], self.params['ma_periods']['short'])
|
| 247 |
+
df['MA20'] = self.calculate_ema(df['close'], self.params['ma_periods']['medium'])
|
| 248 |
+
df['MA60'] = self.calculate_ema(df['close'], self.params['ma_periods']['long'])
|
| 249 |
+
|
| 250 |
+
# 计算RSI
|
| 251 |
+
df['RSI'] = self.calculate_rsi(df['close'], self.params['rsi_period'])
|
| 252 |
+
|
| 253 |
+
# 计算MACD
|
| 254 |
+
df['MACD'], df['Signal'], df['MACD_hist'] = self.calculate_macd(df['close'])
|
| 255 |
+
|
| 256 |
+
# 计算布林带
|
| 257 |
+
df['BB_upper'], df['BB_middle'], df['BB_lower'] = self.calculate_bollinger_bands(
|
| 258 |
+
df['close'],
|
| 259 |
+
self.params['bollinger_period'],
|
| 260 |
+
self.params['bollinger_std']
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# 成交量分析
|
| 264 |
+
df['Volume_MA'] = df['volume'].rolling(window=self.params['volume_ma_period']).mean()
|
| 265 |
+
df['Volume_Ratio'] = df['volume'] / df['Volume_MA']
|
| 266 |
+
|
| 267 |
+
# 计算ATR和波动率
|
| 268 |
+
df['ATR'] = self.calculate_atr(df, self.params['atr_period'])
|
| 269 |
+
df['Volatility'] = df['ATR'] / df['close'] * 100
|
| 270 |
+
|
| 271 |
+
# 动量指标
|
| 272 |
+
df['ROC'] = df['close'].pct_change(periods=10) * 100
|
| 273 |
+
|
| 274 |
+
# 格式化数据
|
| 275 |
+
df = self.format_indicator_data(df)
|
| 276 |
+
|
| 277 |
+
return df
|
| 278 |
+
|
| 279 |
+
except Exception as e:
|
| 280 |
+
self.logger.error(f"计算技术指标时出错: {str(e)}")
|
| 281 |
+
raise
|
| 282 |
+
|
| 283 |
+
def calculate_score(self, df, market_type='A'):
|
| 284 |
+
"""
|
| 285 |
+
计算股票评分 - 使用时空共振交易系统增强
|
| 286 |
+
根据不同的市场特征调整评分权重和标准
|
| 287 |
+
"""
|
| 288 |
+
try:
|
| 289 |
+
score = 0
|
| 290 |
+
latest = df.iloc[-1]
|
| 291 |
+
prev_days = min(30, len(df) - 1) # Get the most recent 30 days or all available data
|
| 292 |
+
|
| 293 |
+
# 时空共振框架 - 维度1:多时间框架分析
|
| 294 |
+
# 基础权重配置
|
| 295 |
+
weights = {
|
| 296 |
+
'trend': 0.30, # 趋势因子权重(日线级别)
|
| 297 |
+
'volatility': 0.15, # 波动率因子权重
|
| 298 |
+
'technical': 0.25, # 技术指标因子权重
|
| 299 |
+
'volume': 0.20, # 成交量因子权重(能量守恒维度)
|
| 300 |
+
'momentum': 0.10 # 动量因子权重(周线级别)
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
# 根据市场类型调整权重(维度1:时间框架嵌套)
|
| 304 |
+
if market_type == 'US':
|
| 305 |
+
# 美股优先考虑长期趋势
|
| 306 |
+
weights['trend'] = 0.35
|
| 307 |
+
weights['volatility'] = 0.10
|
| 308 |
+
weights['momentum'] = 0.15
|
| 309 |
+
elif market_type == 'HK':
|
| 310 |
+
# 港股调整波动率和成交量权重
|
| 311 |
+
weights['volatility'] = 0.20
|
| 312 |
+
weights['volume'] = 0.25
|
| 313 |
+
|
| 314 |
+
# 1. 趋势评分(最高30分)- 日线级别分析
|
| 315 |
+
trend_score = 0
|
| 316 |
+
|
| 317 |
+
# 均线评估 - "三线形态"分析
|
| 318 |
+
if latest['MA5'] > latest['MA20'] and latest['MA20'] > latest['MA60']:
|
| 319 |
+
# 完美多头排列(维度1:日线形态)
|
| 320 |
+
trend_score += 15
|
| 321 |
+
elif latest['MA5'] > latest['MA20']:
|
| 322 |
+
# 短期上升趋势(维度1:5分钟形态)
|
| 323 |
+
trend_score += 10
|
| 324 |
+
elif latest['MA20'] > latest['MA60']:
|
| 325 |
+
# 中期上升趋势
|
| 326 |
+
trend_score += 5
|
| 327 |
+
|
| 328 |
+
# 价格位置评估
|
| 329 |
+
if latest['close'] > latest['MA5']:
|
| 330 |
+
trend_score += 5
|
| 331 |
+
if latest['close'] > latest['MA20']:
|
| 332 |
+
trend_score += 5
|
| 333 |
+
if latest['close'] > latest['MA60']:
|
| 334 |
+
trend_score += 5
|
| 335 |
+
|
| 336 |
+
# 确保不超过最高分数限制
|
| 337 |
+
trend_score = min(30, trend_score)
|
| 338 |
+
|
| 339 |
+
# 2. 波动率评分(最高15分)- 维度2:过滤
|
| 340 |
+
volatility_score = 0
|
| 341 |
+
|
| 342 |
+
# 适度的波动率最理想
|
| 343 |
+
volatility = latest['Volatility']
|
| 344 |
+
if 1.0 <= volatility <= 2.5:
|
| 345 |
+
# 最佳波动率范围
|
| 346 |
+
volatility_score += 15
|
| 347 |
+
elif 2.5 < volatility <= 4.0:
|
| 348 |
+
# 较高波动率,次优选择
|
| 349 |
+
volatility_score += 10
|
| 350 |
+
elif volatility < 1.0:
|
| 351 |
+
# 波动率过低,缺乏能量
|
| 352 |
+
volatility_score += 5
|
| 353 |
+
else:
|
| 354 |
+
# 波动率过高,风险较大
|
| 355 |
+
volatility_score += 0
|
| 356 |
+
|
| 357 |
+
# 3. 技术指标评分(最高25分)- "峰值检测系统"
|
| 358 |
+
technical_score = 0
|
| 359 |
+
|
| 360 |
+
# RSI指标评估(10分)
|
| 361 |
+
rsi = latest['RSI']
|
| 362 |
+
if 40 <= rsi <= 60:
|
| 363 |
+
# 中性区域,趋势稳定
|
| 364 |
+
technical_score += 7
|
| 365 |
+
elif 30 <= rsi < 40 or 60 < rsi <= 70:
|
| 366 |
+
# 阈值区域,可能出现反转信号
|
| 367 |
+
technical_score += 10
|
| 368 |
+
elif rsi < 30:
|
| 369 |
+
# 超卖区域,可能出现买入机会
|
| 370 |
+
technical_score += 8
|
| 371 |
+
elif rsi > 70:
|
| 372 |
+
# 超买区域,可能存在卖出风险
|
| 373 |
+
technical_score += 2
|
| 374 |
+
|
| 375 |
+
# MACD指标评估(10分)- "峰值预警信号"
|
| 376 |
+
if latest['MACD'] > latest['Signal'] and latest['MACD_hist'] > 0:
|
| 377 |
+
# MACD金叉且柱状图为正
|
| 378 |
+
technical_score += 10
|
| 379 |
+
elif latest['MACD'] > latest['Signal']:
|
| 380 |
+
# MACD金叉
|
| 381 |
+
technical_score += 8
|
| 382 |
+
elif latest['MACD'] < latest['Signal'] and latest['MACD_hist'] < 0:
|
| 383 |
+
# MACD死叉且柱状图为负
|
| 384 |
+
technical_score += 0
|
| 385 |
+
elif latest['MACD_hist'] > df.iloc[-2]['MACD_hist']:
|
| 386 |
+
# MACD柱状图增长,可能出现反转信号
|
| 387 |
+
technical_score += 5
|
| 388 |
+
|
| 389 |
+
# 布林带位置评估(5分)
|
| 390 |
+
bb_position = (latest['close'] - latest['BB_lower']) / (latest['BB_upper'] - latest['BB_lower'])
|
| 391 |
+
if 0.3 <= bb_position <= 0.7:
|
| 392 |
+
# 价格在布林带中间区域,趋势稳定
|
| 393 |
+
technical_score += 3
|
| 394 |
+
elif bb_position < 0.2:
|
| 395 |
+
# 价格接近下轨,可能超卖
|
| 396 |
+
technical_score += 5
|
| 397 |
+
elif bb_position > 0.8:
|
| 398 |
+
# 价格接近上轨,可能超买
|
| 399 |
+
technical_score += 1
|
| 400 |
+
|
| 401 |
+
# 确保最大分数限制
|
| 402 |
+
technical_score = min(25, technical_score)
|
| 403 |
+
|
| 404 |
+
# 4. 成交量评分(最高20分)- "能量守恒维度"
|
| 405 |
+
volume_score = 0
|
| 406 |
+
|
| 407 |
+
# 成交量趋势分析
|
| 408 |
+
recent_vol_ratio = [df.iloc[-i]['Volume_Ratio'] for i in range(1, min(6, len(df)))]
|
| 409 |
+
avg_vol_ratio = sum(recent_vol_ratio) / len(recent_vol_ratio)
|
| 410 |
+
|
| 411 |
+
if avg_vol_ratio > 1.5 and latest['close'] > df.iloc[-2]['close']:
|
| 412 |
+
# 成交量放大且价格上涨 - "成交量能量阈值突破"
|
| 413 |
+
volume_score += 20
|
| 414 |
+
elif avg_vol_ratio > 1.2 and latest['close'] > df.iloc[-2]['close']:
|
| 415 |
+
# 成交量和价格同步上涨
|
| 416 |
+
volume_score += 15
|
| 417 |
+
elif avg_vol_ratio < 0.8 and latest['close'] < df.iloc[-2]['close']:
|
| 418 |
+
# 成交量和价格同步下跌,可能是健康回调
|
| 419 |
+
volume_score += 10
|
| 420 |
+
elif avg_vol_ratio > 1.2 and latest['close'] < df.iloc[-2]['close']:
|
| 421 |
+
# 成交量增加但价格下跌,可能存在较大卖压
|
| 422 |
+
volume_score += 0
|
| 423 |
+
else:
|
| 424 |
+
# 其他情况
|
| 425 |
+
volume_score += 8
|
| 426 |
+
|
| 427 |
+
# 5. 动量评分(最高10分)- 维度1:周线级别
|
| 428 |
+
momentum_score = 0
|
| 429 |
+
|
| 430 |
+
# ROC动量指标
|
| 431 |
+
roc = latest['ROC']
|
| 432 |
+
if roc > 5:
|
| 433 |
+
# Strong upward momentum
|
| 434 |
+
momentum_score += 10
|
| 435 |
+
elif 2 <= roc <= 5:
|
| 436 |
+
# Moderate upward momentum
|
| 437 |
+
momentum_score += 8
|
| 438 |
+
elif 0 <= roc < 2:
|
| 439 |
+
# Weak upward momentum
|
| 440 |
+
momentum_score += 5
|
| 441 |
+
elif -2 <= roc < 0:
|
| 442 |
+
# Weak downward momentum
|
| 443 |
+
momentum_score += 3
|
| 444 |
+
else:
|
| 445 |
+
# Strong downward momentum
|
| 446 |
+
momentum_score += 0
|
| 447 |
+
|
| 448 |
+
# 根据加权因子计算总分 - “共振公式”
|
| 449 |
+
final_score = (
|
| 450 |
+
trend_score * weights['trend'] / 0.30 +
|
| 451 |
+
volatility_score * weights['volatility'] / 0.15 +
|
| 452 |
+
technical_score * weights['technical'] / 0.25 +
|
| 453 |
+
volume_score * weights['volume'] / 0.20 +
|
| 454 |
+
momentum_score * weights['momentum'] / 0.10
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
# 特殊市场调整 - “市场适应机制”
|
| 458 |
+
if market_type == 'US':
|
| 459 |
+
# 美国市场额外调整因素
|
| 460 |
+
# 检查是否为财报季
|
| 461 |
+
is_earnings_season = self._is_earnings_season()
|
| 462 |
+
if is_earnings_season:
|
| 463 |
+
# Earnings season has higher volatility, adjust score certainty
|
| 464 |
+
final_score = 0.9 * final_score + 5 # Slight regression to the mean
|
| 465 |
+
|
| 466 |
+
elif market_type == 'HK':
|
| 467 |
+
# 港股特殊调整
|
| 468 |
+
# 检查A股联动效应
|
| 469 |
+
a_share_linkage = self._check_a_share_linkage(df)
|
| 470 |
+
if a_share_linkage > 0.7: # High linkage
|
| 471 |
+
# 根据大陆市场情绪调整
|
| 472 |
+
mainland_sentiment = self._get_mainland_market_sentiment()
|
| 473 |
+
if mainland_sentiment > 0:
|
| 474 |
+
final_score += 5
|
| 475 |
+
else:
|
| 476 |
+
final_score -= 5
|
| 477 |
+
|
| 478 |
+
# Ensure score remains within 0-100 range
|
| 479 |
+
final_score = max(0, min(100, round(final_score)))
|
| 480 |
+
|
| 481 |
+
# Store sub-scores for display
|
| 482 |
+
self.score_details = {
|
| 483 |
+
'trend': trend_score,
|
| 484 |
+
'volatility': volatility_score,
|
| 485 |
+
'technical': technical_score,
|
| 486 |
+
'volume': volume_score,
|
| 487 |
+
'momentum': momentum_score,
|
| 488 |
+
'total': final_score
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
return final_score
|
| 492 |
+
|
| 493 |
+
except Exception as e:
|
| 494 |
+
self.logger.error(f"Error calculating score: {str(e)}")
|
| 495 |
+
# Return neutral score on error
|
| 496 |
+
return 50
|
| 497 |
+
|
| 498 |
+
def calculate_position_size(self, stock_code, risk_percent=2.0, stop_loss_percent=5.0):
|
| 499 |
+
"""
|
| 500 |
+
根据风险管理原则计算最佳仓位大小
|
| 501 |
+
实施时空共振系统的“仓位大小公式”
|
| 502 |
+
|
| 503 |
+
参数:
|
| 504 |
+
stock_code: 要分析的股票代码
|
| 505 |
+
risk_percent: 在此交易中承担风险的总资本百分比(默认为2%)
|
| 506 |
+
stop_loss_percent: 从入场点的止损百分比(默认为5��)
|
| 507 |
+
|
| 508 |
+
返回:
|
| 509 |
+
仓位大小占总资本的百分比
|
| 510 |
+
"""
|
| 511 |
+
try:
|
| 512 |
+
# Get stock data
|
| 513 |
+
df = self.get_stock_data(stock_code)
|
| 514 |
+
df = self.calculate_indicators(df)
|
| 515 |
+
|
| 516 |
+
# 获取波动率因子(来自维度3:能量守恒)
|
| 517 |
+
latest = df.iloc[-1]
|
| 518 |
+
volatility = latest['Volatility']
|
| 519 |
+
|
| 520 |
+
# 计算波动率调整因子(较高波动率=较小仓位)
|
| 521 |
+
volatility_factor = 1.0
|
| 522 |
+
if volatility > 4.0:
|
| 523 |
+
volatility_factor = 0.6 # Reduce position for high volatility stocks
|
| 524 |
+
elif volatility > 2.5:
|
| 525 |
+
volatility_factor = 0.8 # Slightly reduce position
|
| 526 |
+
elif volatility < 1.0:
|
| 527 |
+
volatility_factor = 1.2 # Can increase position for low volatility stocks
|
| 528 |
+
|
| 529 |
+
# Calculate position size using risk formula
|
| 530 |
+
# 公式:position_size = (风险金额) / (止损 * 波动率因子)
|
| 531 |
+
position_size = (risk_percent) / (stop_loss_percent * volatility_factor)
|
| 532 |
+
|
| 533 |
+
# 限制最大仓位为25%以实现多元化
|
| 534 |
+
position_size = min(position_size, 25.0)
|
| 535 |
+
|
| 536 |
+
return position_size
|
| 537 |
+
|
| 538 |
+
except Exception as e:
|
| 539 |
+
self.logger.error(f"Error calculating position size: {str(e)}")
|
| 540 |
+
# 返回保守的默认仓位大小(出错时)
|
| 541 |
+
return 5.0
|
| 542 |
+
|
| 543 |
+
def get_recommendation(self, score, market_type='A', technical_data=None, news_data=None):
|
| 544 |
+
"""
|
| 545 |
+
根据得分和附加信息生成投资建议
|
| 546 |
+
使用时空共振交易系统策略增强
|
| 547 |
+
"""
|
| 548 |
+
try:
|
| 549 |
+
# 1. Base recommendation logic - Dynamic threshold adjustment based on score
|
| 550 |
+
if score >= 85:
|
| 551 |
+
base_recommendation = '强烈建议买入'
|
| 552 |
+
confidence = 'high'
|
| 553 |
+
action = 'strong_buy'
|
| 554 |
+
elif score >= 70:
|
| 555 |
+
base_recommendation = '建议买入'
|
| 556 |
+
confidence = 'medium_high'
|
| 557 |
+
action = 'buy'
|
| 558 |
+
elif score >= 55:
|
| 559 |
+
base_recommendation = '谨慎买入'
|
| 560 |
+
confidence = 'medium'
|
| 561 |
+
action = 'cautious_buy'
|
| 562 |
+
elif score >= 45:
|
| 563 |
+
base_recommendation = '持观望态度'
|
| 564 |
+
confidence = 'medium'
|
| 565 |
+
action = 'hold'
|
| 566 |
+
elif score >= 30:
|
| 567 |
+
base_recommendation = '谨慎持有'
|
| 568 |
+
confidence = 'medium'
|
| 569 |
+
action = 'cautious_hold'
|
| 570 |
+
elif score >= 15:
|
| 571 |
+
base_recommendation = '建议减仓'
|
| 572 |
+
confidence = 'medium_high'
|
| 573 |
+
action = 'reduce'
|
| 574 |
+
else:
|
| 575 |
+
base_recommendation = '建议卖出'
|
| 576 |
+
confidence = 'high'
|
| 577 |
+
action = 'sell'
|
| 578 |
+
|
| 579 |
+
# 2. Consider market characteristics (Dimension 1: Timeframe Nesting)
|
| 580 |
+
market_adjustment = ""
|
| 581 |
+
if market_type == 'US':
|
| 582 |
+
# US market adjustment factors
|
| 583 |
+
if self._is_earnings_season():
|
| 584 |
+
if confidence == 'high' or confidence == 'medium_high':
|
| 585 |
+
confidence = 'medium'
|
| 586 |
+
market_adjustment = "(财报季临近,波动可能加大,建议适当控制仓位)"
|
| 587 |
+
|
| 588 |
+
elif market_type == 'HK':
|
| 589 |
+
# HK market adjustment factors
|
| 590 |
+
mainland_sentiment = self._get_mainland_market_sentiment()
|
| 591 |
+
if mainland_sentiment < -0.3 and (action == 'buy' or action == 'strong_buy'):
|
| 592 |
+
action = 'cautious_buy'
|
| 593 |
+
confidence = 'medium'
|
| 594 |
+
market_adjustment = "(受大陆市场情绪影响,建议控制风险)"
|
| 595 |
+
|
| 596 |
+
elif market_type == 'A':
|
| 597 |
+
# A-share specific adjustment factors
|
| 598 |
+
if technical_data and 'Volatility' in technical_data:
|
| 599 |
+
vol = technical_data.get('Volatility', 0)
|
| 600 |
+
if vol > 4.0 and (action == 'buy' or action == 'strong_buy'):
|
| 601 |
+
action = 'cautious_buy'
|
| 602 |
+
confidence = 'medium'
|
| 603 |
+
market_adjustment = "(市场波动较大,建议分批买入)"
|
| 604 |
+
|
| 605 |
+
# 3. Consider market sentiment (Dimension 2: Filtering)
|
| 606 |
+
sentiment_adjustment = ""
|
| 607 |
+
if news_data and 'market_sentiment' in news_data:
|
| 608 |
+
sentiment = news_data.get('market_sentiment', 'neutral')
|
| 609 |
+
|
| 610 |
+
if sentiment == 'bullish' and action in ['hold', 'cautious_hold']:
|
| 611 |
+
action = 'cautious_buy'
|
| 612 |
+
sentiment_adjustment = "(市场氛围积极,可适当提高仓位)"
|
| 613 |
+
|
| 614 |
+
elif sentiment == 'bearish' and action in ['buy', 'cautious_buy']:
|
| 615 |
+
action = 'hold'
|
| 616 |
+
sentiment_adjustment = "(市场氛围悲观,建议等���更好买点)"
|
| 617 |
+
elif self.json_match_flag==False:
|
| 618 |
+
import re
|
| 619 |
+
|
| 620 |
+
# 如果JSON解析失败,尝试从原始内容中匹配市场情绪
|
| 621 |
+
sentiment_pattern = r'(bullish|neutral|bearish)'
|
| 622 |
+
sentiment_match = re.search(sentiment_pattern, news_data.get('original_content', ''))
|
| 623 |
+
|
| 624 |
+
if sentiment_match:
|
| 625 |
+
sentiment_map = {
|
| 626 |
+
'bullish': 'bullish',
|
| 627 |
+
'neutral': 'neutral',
|
| 628 |
+
'bearish': 'bearish'
|
| 629 |
+
}
|
| 630 |
+
sentiment = sentiment_map.get(sentiment_match.group(1), 'neutral')
|
| 631 |
+
|
| 632 |
+
if sentiment == 'bullish' and action in ['hold', 'cautious_hold']:
|
| 633 |
+
action = 'cautious_buy'
|
| 634 |
+
sentiment_adjustment = "(市场氛围积极,可适当提高仓位)"
|
| 635 |
+
elif sentiment == 'bearish' and action in ['buy', 'cautious_buy']:
|
| 636 |
+
action = 'hold'
|
| 637 |
+
sentiment_adjustment = "(市场氛围悲观,建议等待更好买点)"
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
# 4. Technical indicators adjustment (Dimension 2: "Peak Detection System")
|
| 641 |
+
technical_adjustment = ""
|
| 642 |
+
if technical_data:
|
| 643 |
+
rsi = technical_data.get('RSI', 50)
|
| 644 |
+
macd_signal = technical_data.get('MACD_signal', 'neutral')
|
| 645 |
+
|
| 646 |
+
# RSI overbought/oversold adjustment
|
| 647 |
+
if rsi > 80 and action in ['buy', 'strong_buy']:
|
| 648 |
+
action = 'hold'
|
| 649 |
+
technical_adjustment = "(RSI指标显示超买,建议等待回调)"
|
| 650 |
+
elif rsi < 20 and action in ['sell', 'reduce']:
|
| 651 |
+
action = 'hold'
|
| 652 |
+
technical_adjustment = "(RSI指标显示超卖,可能存在反弹机会)"
|
| 653 |
+
|
| 654 |
+
# MACD signal adjustment
|
| 655 |
+
if macd_signal == 'bullish' and action in ['hold', 'cautious_hold']:
|
| 656 |
+
action = 'cautious_buy'
|
| 657 |
+
if not technical_adjustment:
|
| 658 |
+
technical_adjustment = "(MACD显示买入信号)"
|
| 659 |
+
elif macd_signal == 'bearish' and action in ['cautious_buy', 'buy']:
|
| 660 |
+
action = 'hold'
|
| 661 |
+
if not technical_adjustment:
|
| 662 |
+
technical_adjustment = "(MACD显示卖出信号)"
|
| 663 |
+
|
| 664 |
+
# 5. Convert adjusted action to final recommendation
|
| 665 |
+
action_to_recommendation = {
|
| 666 |
+
'strong_buy': '强烈建议买入',
|
| 667 |
+
'buy': '建议买入',
|
| 668 |
+
'cautious_buy': '谨慎买入',
|
| 669 |
+
'hold': '持观望态度',
|
| 670 |
+
'cautious_hold': '谨慎持有',
|
| 671 |
+
'reduce': '建议减仓',
|
| 672 |
+
'sell': '建议卖出'
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
final_recommendation = action_to_recommendation.get(action, base_recommendation)
|
| 676 |
+
|
| 677 |
+
# 6. Combine all adjustment factors
|
| 678 |
+
adjustments = " ".join(filter(None, [market_adjustment, sentiment_adjustment, technical_adjustment]))
|
| 679 |
+
|
| 680 |
+
if adjustments:
|
| 681 |
+
return f"{final_recommendation} {adjustments}"
|
| 682 |
+
else:
|
| 683 |
+
return final_recommendation
|
| 684 |
+
|
| 685 |
+
except Exception as e:
|
| 686 |
+
self.logger.error(f"Error generating investment recommendation: {str(e)}")
|
| 687 |
+
# Return safe default recommendation on error
|
| 688 |
+
return "无法提供明确建议,请结合多种因素谨慎决策"
|
| 689 |
+
|
| 690 |
+
def check_consecutive_losses(self, trade_history, max_consecutive_losses=3):
|
| 691 |
+
"""
|
| 692 |
+
实施“冷静期风险控制” - 连续亏损后停止交易
|
| 693 |
+
|
| 694 |
+
参数:
|
| 695 |
+
trade_history: 最近交易结果列表 (True 表示盈利, False 表示亏损)
|
| 696 |
+
max_consecutive_losses: 允许的最大连续亏损次数
|
| 697 |
+
|
| 698 |
+
返回:
|
| 699 |
+
Boolean: True 如果应该暂停交易, False 如果可以继续交易
|
| 700 |
+
"""
|
| 701 |
+
consecutive_losses = 0
|
| 702 |
+
|
| 703 |
+
# Count consecutive losses from most recent trades
|
| 704 |
+
for trade in reversed(trade_history):
|
| 705 |
+
if not trade: # If trade is a loss
|
| 706 |
+
consecutive_losses += 1
|
| 707 |
+
else:
|
| 708 |
+
break # Break on first profitable trade
|
| 709 |
+
|
| 710 |
+
# Return True if we've hit max consecutive losses
|
| 711 |
+
return consecutive_losses >= max_consecutive_losses
|
| 712 |
+
|
| 713 |
+
def check_profit_taking(self, current_profit_percent, threshold=20.0):
|
| 714 |
+
"""
|
| 715 |
+
当回报超过阈值时,实施获利了结机制
|
| 716 |
+
属于“能量守恒维度”的一部分
|
| 717 |
+
|
| 718 |
+
参数:
|
| 719 |
+
current_profit_percent: 当前利润百分比
|
| 720 |
+
threshold: 用于获利了结的利润百分比阈值
|
| 721 |
+
|
| 722 |
+
返回:
|
| 723 |
+
Float: 减少仓位的百分比 (0.0-1.0)
|
| 724 |
+
"""
|
| 725 |
+
if current_profit_percent >= threshold:
|
| 726 |
+
# If profit exceeds threshold, suggest reducing position by 50%
|
| 727 |
+
return 0.5
|
| 728 |
+
|
| 729 |
+
return 0.0 # No position reduction recommended
|
| 730 |
+
|
| 731 |
+
def _is_earnings_season(self):
|
| 732 |
+
"""检查当前是否处于财报季(辅助函数)"""
|
| 733 |
+
from datetime import datetime
|
| 734 |
+
current_month = datetime.now().month
|
| 735 |
+
# 美股财报季大致在1月、4月、7月和10月
|
| 736 |
+
return current_month in [1, 4, 7, 10]
|
| 737 |
+
|
| 738 |
+
def _check_a_share_linkage(self, df, window=20):
|
| 739 |
+
"""检查港股与A股的联动性(辅助函数)"""
|
| 740 |
+
# 该函数需要获取对应的A股指数数据
|
| 741 |
+
# 简化版实现:
|
| 742 |
+
try:
|
| 743 |
+
# 获取恒生指数与上证指数的相关系数
|
| 744 |
+
# 实际实现中需要获取真实数据
|
| 745 |
+
correlation = 0.6 # 示例值
|
| 746 |
+
return correlation
|
| 747 |
+
except:
|
| 748 |
+
return 0.5 # 默认中等关联度
|
| 749 |
+
|
| 750 |
+
def _get_mainland_market_sentiment(self):
|
| 751 |
+
"""获取中国大陆市场情绪(辅助函数)"""
|
| 752 |
+
# 实际实现中需要分析上证指数、北向资金等因素
|
| 753 |
+
try:
|
| 754 |
+
# 简化版实现,返回-1到1之间的值,1表示积极情绪
|
| 755 |
+
sentiment = 0.2 # 示例值
|
| 756 |
+
return sentiment
|
| 757 |
+
except:
|
| 758 |
+
return 0 # 默认中性情绪
|
| 759 |
+
|
| 760 |
+
def get_stock_news(self, stock_code, market_type='A', limit=5):
|
| 761 |
+
"""
|
| 762 |
+
获取股票相关新闻和实时信息,通过OpenAI API调用function calling方式获取
|
| 763 |
+
参数:
|
| 764 |
+
stock_code: 股票代码
|
| 765 |
+
market_type: 市场类型 (A/HK/US)
|
| 766 |
+
limit: 返回的新闻条数上限
|
| 767 |
+
返回:
|
| 768 |
+
包含新闻和公告的字典
|
| 769 |
+
"""
|
| 770 |
+
try:
|
| 771 |
+
self.logger.info(f"获取股票 {stock_code} 的相关新闻和信息")
|
| 772 |
+
|
| 773 |
+
# 缓存键
|
| 774 |
+
cache_key = f"{stock_code}_{market_type}_news"
|
| 775 |
+
if cache_key in self.data_cache and (
|
| 776 |
+
datetime.now() - self.data_cache[cache_key]['timestamp']).seconds < 3600:
|
| 777 |
+
# 缓存1小时内的数据
|
| 778 |
+
return self.data_cache[cache_key]['data']
|
| 779 |
+
|
| 780 |
+
# 获取股票基本信息
|
| 781 |
+
stock_info = self.get_stock_info(stock_code)
|
| 782 |
+
stock_name = stock_info.get('股票名称', '未知')
|
| 783 |
+
industry = stock_info.get('行业', '未知')
|
| 784 |
+
|
| 785 |
+
# 构建新闻查询的prompt
|
| 786 |
+
market_name = "A股" if market_type == 'A' else "港股" if market_type == 'HK' else "美股"
|
| 787 |
+
query = f"""请帮我搜索以下股票的最新相关新闻和信息:
|
| 788 |
+
股票名称: {stock_name}
|
| 789 |
+
股票代码: {stock_code}
|
| 790 |
+
市场: {market_name}
|
| 791 |
+
行业: {industry}
|
| 792 |
+
|
| 793 |
+
请使用search_news工具搜索相关新闻,然后只需要返回JSON格式。
|
| 794 |
+
按照以下格式的JSON数据返回:
|
| 795 |
+
{{
|
| 796 |
+
"news": [
|
| 797 |
+
{{"title": "新闻标题", "date": "YYYY-MM-DD", "source": "新闻来源", "summary": "新闻摘要"}},
|
| 798 |
+
...
|
| 799 |
+
],
|
| 800 |
+
"announcements": [
|
| 801 |
+
{{"title": "公告标题", "date": "YYYY-MM-DD", "type": "公告类型"}},
|
| 802 |
+
...
|
| 803 |
+
],
|
| 804 |
+
"industry_news": [
|
| 805 |
+
{{"title": "行业新闻标题", "date": "YYYY-MM-DD", "summary": "新闻摘要"}},
|
| 806 |
+
...
|
| 807 |
+
],
|
| 808 |
+
"market_sentiment": "市场情绪(bullish/slightly_bullish/neutral/slightly_bearish/bearish)"
|
| 809 |
+
}}
|
| 810 |
+
注意只返回json数据,不要返回其他内容。
|
| 811 |
+
"""
|
| 812 |
+
|
| 813 |
+
# 定义函数调用工具
|
| 814 |
+
tools = [
|
| 815 |
+
{
|
| 816 |
+
"type": "function",
|
| 817 |
+
"function": {
|
| 818 |
+
"name": "search_news",
|
| 819 |
+
"description": "搜索股票相关的新闻和信息",
|
| 820 |
+
"parameters": {
|
| 821 |
+
"type": "object",
|
| 822 |
+
"properties": {
|
| 823 |
+
"query": {
|
| 824 |
+
"type": "string",
|
| 825 |
+
"description": "搜索查询词,用于查找相关新闻"
|
| 826 |
+
}
|
| 827 |
+
},
|
| 828 |
+
"required": ["query"]
|
| 829 |
+
}
|
| 830 |
+
}
|
| 831 |
+
}
|
| 832 |
+
]
|
| 833 |
+
|
| 834 |
+
# 使用线程和队列添加超时控制
|
| 835 |
+
import queue
|
| 836 |
+
import threading
|
| 837 |
+
import json
|
| 838 |
+
import openai
|
| 839 |
+
import requests
|
| 840 |
+
|
| 841 |
+
result_queue = queue.Queue()
|
| 842 |
+
|
| 843 |
+
def search_news(query):
|
| 844 |
+
"""实际执行搜索的函数"""
|
| 845 |
+
try:
|
| 846 |
+
# 获取SERP API密钥
|
| 847 |
+
serp_api_key = os.getenv('SERP_API_KEY')
|
| 848 |
+
if not serp_api_key:
|
| 849 |
+
self.logger.error("未找到SERP_API_KEY环境变量")
|
| 850 |
+
return {"error": "未配置搜索API密钥"}
|
| 851 |
+
|
| 852 |
+
# 构建搜索查询
|
| 853 |
+
search_query = f"{stock_name} {stock_code} {market_name} 最新新闻 公告"
|
| 854 |
+
|
| 855 |
+
# 调用SERP API
|
| 856 |
+
url = "https://serpapi.com/search"
|
| 857 |
+
params = {
|
| 858 |
+
"engine": "google",
|
| 859 |
+
"q": search_query,
|
| 860 |
+
"api_key": serp_api_key,
|
| 861 |
+
"tbm": "nws", # 新闻搜索
|
| 862 |
+
"num": limit * 2 # 获取更多结果以便筛选
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
response = requests.get(url, params=params)
|
| 866 |
+
search_results = response.json()
|
| 867 |
+
|
| 868 |
+
# 提取新闻结果
|
| 869 |
+
news_results = []
|
| 870 |
+
if "news_results" in search_results:
|
| 871 |
+
for item in search_results["news_results"][:limit]:
|
| 872 |
+
news_results.append({
|
| 873 |
+
"title": item.get("title", ""),
|
| 874 |
+
"date": item.get("date", ""),
|
| 875 |
+
"source": item.get("source", ""),
|
| 876 |
+
"link": item.get("link", ""),
|
| 877 |
+
"snippet": item.get("snippet", "")
|
| 878 |
+
})
|
| 879 |
+
|
| 880 |
+
# 构建行业新闻查询
|
| 881 |
+
industry_query = f"{industry} {market_name} 行业动态 最新消息"
|
| 882 |
+
industry_params = {
|
| 883 |
+
"engine": "google",
|
| 884 |
+
"q": industry_query,
|
| 885 |
+
"api_key": serp_api_key,
|
| 886 |
+
"tbm": "nws",
|
| 887 |
+
"num": limit
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
industry_response = requests.get(url, params=industry_params)
|
| 891 |
+
industry_results = industry_response.json()
|
| 892 |
+
|
| 893 |
+
# 提取行业新闻
|
| 894 |
+
industry_news = []
|
| 895 |
+
if "news_results" in industry_results:
|
| 896 |
+
for item in industry_results["news_results"][:limit]:
|
| 897 |
+
industry_news.append({
|
| 898 |
+
"title": item.get("title", ""),
|
| 899 |
+
"date": item.get("date", ""),
|
| 900 |
+
"source": item.get("source", ""),
|
| 901 |
+
"summary": item.get("snippet", "")
|
| 902 |
+
})
|
| 903 |
+
|
| 904 |
+
# 获取公告信息 (可能需要专门的API或网站爬取)
|
| 905 |
+
# 这里简化处理,实际应用中可能需要更复杂的逻辑
|
| 906 |
+
announcements = []
|
| 907 |
+
|
| 908 |
+
# 分析市场情绪
|
| 909 |
+
# 简单实现:基于新闻标题和摘要的关键词分析
|
| 910 |
+
sentiment_keywords = {
|
| 911 |
+
'bullish': ['上涨', '增长', '利好', '突破', '强势', '看好', '机会', '利润'],
|
| 912 |
+
'slightly_bullish': ['回升', '改善', '企稳', '向好', '期待'],
|
| 913 |
+
'neutral': ['稳定', '平稳', '持平', '不变'],
|
| 914 |
+
'slightly_bearish': ['回调', '承压', '谨慎', '风险', '下滑'],
|
| 915 |
+
'bearish': ['下跌', '亏损', '跌破', '利空', '警惕', '危机', '崩盘']
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
# 计算情绪得分
|
| 919 |
+
sentiment_scores = {k: 0 for k in sentiment_keywords.keys()}
|
| 920 |
+
all_text = " ".join([n.get("title", "") + " " + n.get("snippet", "") for n in news_results])
|
| 921 |
+
|
| 922 |
+
for sentiment, keywords in sentiment_keywords.items():
|
| 923 |
+
for keyword in keywords:
|
| 924 |
+
if keyword in all_text:
|
| 925 |
+
sentiment_scores[sentiment] += 1
|
| 926 |
+
|
| 927 |
+
# 确定主导情绪
|
| 928 |
+
if not sentiment_scores or all(score == 0 for score in sentiment_scores.values()):
|
| 929 |
+
market_sentiment = "neutral"
|
| 930 |
+
else:
|
| 931 |
+
market_sentiment = max(sentiment_scores.items(), key=lambda x: x[1])[0]
|
| 932 |
+
|
| 933 |
+
return {
|
| 934 |
+
"news": news_results,
|
| 935 |
+
"announcements": announcements,
|
| 936 |
+
"industry_news": industry_news,
|
| 937 |
+
"market_sentiment": market_sentiment
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
except Exception as e:
|
| 941 |
+
self.logger.error(f"搜索新闻时出错: {str(e)}")
|
| 942 |
+
return {"error": str(e)}
|
| 943 |
+
|
| 944 |
+
def call_api():
|
| 945 |
+
try:
|
| 946 |
+
messages = [{"role": "user", "content": query}]
|
| 947 |
+
|
| 948 |
+
# 第一步:调用模型,让它决定使用工具
|
| 949 |
+
response = openai.ChatCompletion.create(
|
| 950 |
+
model=self.news_model,
|
| 951 |
+
messages=messages,
|
| 952 |
+
tools=tools,
|
| 953 |
+
tool_choice="auto",
|
| 954 |
+
temperature=0.7,
|
| 955 |
+
max_tokens=1000,
|
| 956 |
+
stream=False,
|
| 957 |
+
timeout=120
|
| 958 |
+
)
|
| 959 |
+
|
| 960 |
+
# 检查是否有工具调用
|
| 961 |
+
message = response["choices"][0]["message"]
|
| 962 |
+
|
| 963 |
+
if "tool_calls" in message:
|
| 964 |
+
# 处理工具调用
|
| 965 |
+
tool_calls = message["tool_calls"]
|
| 966 |
+
|
| 967 |
+
# 准备新的消息列表,包含工具调用结果
|
| 968 |
+
messages.append(message) # 添加助手的消息
|
| 969 |
+
|
| 970 |
+
for tool_call in tool_calls:
|
| 971 |
+
function_name = tool_call["function"]["name"]
|
| 972 |
+
function_args = json.loads(tool_call["function"]["arguments"])
|
| 973 |
+
|
| 974 |
+
# 执行搜索
|
| 975 |
+
if function_name == "search_news":
|
| 976 |
+
search_query = function_args.get("query", f"{stock_name} {stock_code} 新闻")
|
| 977 |
+
function_response = search_news(search_query)
|
| 978 |
+
|
| 979 |
+
# 添加工具响应到消息
|
| 980 |
+
messages.append({
|
| 981 |
+
"tool_call_id": tool_call["id"],
|
| 982 |
+
"role": "tool",
|
| 983 |
+
"name": function_name,
|
| 984 |
+
"content": json.dumps(function_response, ensure_ascii=False)
|
| 985 |
+
})
|
| 986 |
+
|
| 987 |
+
# 第二步:让模型处理搜索结果并生成最终响应
|
| 988 |
+
second_response = openai.ChatCompletion.create(
|
| 989 |
+
model=self.news_model,
|
| 990 |
+
messages=messages,
|
| 991 |
+
temperature=0.7,
|
| 992 |
+
max_tokens=4000,
|
| 993 |
+
stream=False,
|
| 994 |
+
timeout=120
|
| 995 |
+
)
|
| 996 |
+
|
| 997 |
+
result_queue.put(second_response)
|
| 998 |
+
else:
|
| 999 |
+
# 如果模型没有选择使用工具,直接使用第一次响应
|
| 1000 |
+
result_queue.put(response)
|
| 1001 |
+
|
| 1002 |
+
except Exception as e:
|
| 1003 |
+
result_queue.put(e)
|
| 1004 |
+
|
| 1005 |
+
# 启动API调用线程
|
| 1006 |
+
api_thread = threading.Thread(target=call_api)
|
| 1007 |
+
api_thread.daemon = True
|
| 1008 |
+
api_thread.start()
|
| 1009 |
+
|
| 1010 |
+
# 等待结果,最多等待240秒
|
| 1011 |
+
try:
|
| 1012 |
+
result = result_queue.get(timeout=240)
|
| 1013 |
+
|
| 1014 |
+
# 检查结果是否为异常
|
| 1015 |
+
if isinstance(result, Exception):
|
| 1016 |
+
self.logger.error(f"获取新闻API调用失败: {str(result)}")
|
| 1017 |
+
raise result
|
| 1018 |
+
|
| 1019 |
+
# 提取回复内容
|
| 1020 |
+
content = result["choices"][0]["message"]["content"].strip()
|
| 1021 |
+
|
| 1022 |
+
# 尝试解析JSON,但如果失败则保留原始内容
|
| 1023 |
+
try:
|
| 1024 |
+
# 尝试直接解析JSON
|
| 1025 |
+
news_data = json.loads(content)
|
| 1026 |
+
except json.JSONDecodeError:
|
| 1027 |
+
# 如果直接解析失败,尝试提取JSON部分
|
| 1028 |
+
import re
|
| 1029 |
+
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', content)
|
| 1030 |
+
if json_match:
|
| 1031 |
+
json_str = json_match.group(1)
|
| 1032 |
+
news_data = json.loads(json_str)
|
| 1033 |
+
self.json_match_flag = True
|
| 1034 |
+
else:
|
| 1035 |
+
# 如果仍然无法提取JSON,尝试直接返回响应
|
| 1036 |
+
self.logger.info(f"无法提取JSON,直接返回响应{content}")
|
| 1037 |
+
self.json_match_flag = False
|
| 1038 |
+
news_data = {}
|
| 1039 |
+
news_data['original_content'] = content
|
| 1040 |
+
|
| 1041 |
+
# 确保数据结构完整
|
| 1042 |
+
if not isinstance(news_data, dict):
|
| 1043 |
+
news_data = {}
|
| 1044 |
+
|
| 1045 |
+
for key in ['news', 'announcements', 'industry_news']:
|
| 1046 |
+
if key not in news_data:
|
| 1047 |
+
news_data[key] = []
|
| 1048 |
+
|
| 1049 |
+
if 'market_sentiment' not in news_data:
|
| 1050 |
+
news_data['market_sentiment'] = 'neutral'
|
| 1051 |
+
|
| 1052 |
+
# 添加时间戳
|
| 1053 |
+
news_data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1054 |
+
|
| 1055 |
+
# 缓存结果
|
| 1056 |
+
self.data_cache[cache_key] = {
|
| 1057 |
+
'data': news_data,
|
| 1058 |
+
'timestamp': datetime.now()
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
return news_data
|
| 1062 |
+
|
| 1063 |
+
except queue.Empty:
|
| 1064 |
+
self.logger.warning("获取新闻API调用超时")
|
| 1065 |
+
return {
|
| 1066 |
+
'news': [],
|
| 1067 |
+
'announcements': [],
|
| 1068 |
+
'industry_news': [],
|
| 1069 |
+
'market_sentiment': 'neutral',
|
| 1070 |
+
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1071 |
+
}
|
| 1072 |
+
except Exception as e:
|
| 1073 |
+
self.logger.error(f"处理新闻数据时出错: {str(e)}")
|
| 1074 |
+
return {
|
| 1075 |
+
'news': [],
|
| 1076 |
+
'announcements': [],
|
| 1077 |
+
'industry_news': [],
|
| 1078 |
+
'market_sentiment': 'neutral',
|
| 1079 |
+
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
except Exception as e:
|
| 1083 |
+
self.logger.error(f"获取股票新闻时出错: {str(e)}")
|
| 1084 |
+
# 出错时返回空结果
|
| 1085 |
+
return {
|
| 1086 |
+
'news': [],
|
| 1087 |
+
'announcements': [],
|
| 1088 |
+
'industry_news': [],
|
| 1089 |
+
'market_sentiment': 'neutral',
|
| 1090 |
+
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
# def get_recommendation(self, score, market_type='A', technical_data=None, news_data=None):
|
| 1094 |
+
# """
|
| 1095 |
+
# 根据得分和附加信息给出平滑的投资建议
|
| 1096 |
+
#
|
| 1097 |
+
# 参数:
|
| 1098 |
+
# score: 股票综合评分 (0-100)
|
| 1099 |
+
# market_type: 市场类型 (A/HK/US)
|
| 1100 |
+
# technical_data: 技术指标数据 (可选)
|
| 1101 |
+
# news_data: 新闻和市场情绪数据 (可选)
|
| 1102 |
+
#
|
| 1103 |
+
# 返回:
|
| 1104 |
+
# 投资建议字符串
|
| 1105 |
+
# """
|
| 1106 |
+
# try:
|
| 1107 |
+
# # 1. 基础建议逻辑 - 基于分数的平滑建议
|
| 1108 |
+
# if score >= 85:
|
| 1109 |
+
# base_recommendation = '强烈建议买入'
|
| 1110 |
+
# confidence = 'high'
|
| 1111 |
+
# action = 'strong_buy'
|
| 1112 |
+
# elif score >= 70:
|
| 1113 |
+
# base_recommendation = '建议买入'
|
| 1114 |
+
# confidence = 'medium_high'
|
| 1115 |
+
# action = 'buy'
|
| 1116 |
+
# elif score >= 55:
|
| 1117 |
+
# base_recommendation = '谨慎买入'
|
| 1118 |
+
# confidence = 'medium'
|
| 1119 |
+
# action = 'cautious_buy'
|
| 1120 |
+
# elif score >= 45:
|
| 1121 |
+
# base_recommendation = '持观望态度'
|
| 1122 |
+
# confidence = 'medium'
|
| 1123 |
+
# action = 'hold'
|
| 1124 |
+
# elif score >= 30:
|
| 1125 |
+
# base_recommendation = '谨慎持有'
|
| 1126 |
+
# confidence = 'medium'
|
| 1127 |
+
# action = 'cautious_hold'
|
| 1128 |
+
# elif score >= 15:
|
| 1129 |
+
# base_recommendation = '建议减仓'
|
| 1130 |
+
# confidence = 'medium_high'
|
| 1131 |
+
# action = 'reduce'
|
| 1132 |
+
# else:
|
| 1133 |
+
# base_recommendation = '建议卖出'
|
| 1134 |
+
# confidence = 'high'
|
| 1135 |
+
# action = 'sell'
|
| 1136 |
+
#
|
| 1137 |
+
# # 2. 考虑市场特性
|
| 1138 |
+
# market_adjustment = ""
|
| 1139 |
+
# if market_type == 'US':
|
| 1140 |
+
# # 美股调整因素
|
| 1141 |
+
# if self._is_earnings_season():
|
| 1142 |
+
# if confidence == 'high' or confidence == 'medium_high':
|
| 1143 |
+
# confidence = 'medium'
|
| 1144 |
+
# market_adjustment = "(财报季临近,波动可能加大,建议适当控制仓位)"
|
| 1145 |
+
#
|
| 1146 |
+
# elif market_type == 'HK':
|
| 1147 |
+
# # 港股调整因素
|
| 1148 |
+
# mainland_sentiment = self._get_mainland_market_sentiment()
|
| 1149 |
+
# if mainland_sentiment < -0.3 and (action == 'buy' or action == 'strong_buy'):
|
| 1150 |
+
# action = 'cautious_buy'
|
| 1151 |
+
# confidence = 'medium'
|
| 1152 |
+
# market_adjustment = "(受大陆市场情绪影响,建议控制风险)"
|
| 1153 |
+
#
|
| 1154 |
+
# elif market_type == 'A':
|
| 1155 |
+
# # A股特有调整因素
|
| 1156 |
+
# if technical_data and 'Volatility' in technical_data:
|
| 1157 |
+
# vol = technical_data.get('Volatility', 0)
|
| 1158 |
+
# if vol > 4.0 and (action == 'buy' or action == 'strong_buy'):
|
| 1159 |
+
# action = 'cautious_buy'
|
| 1160 |
+
# confidence = 'medium'
|
| 1161 |
+
# market_adjustment = "(市场波动较大,建议分批买入)"
|
| 1162 |
+
#
|
| 1163 |
+
# # 3. 考虑市场���绪
|
| 1164 |
+
# sentiment_adjustment = ""
|
| 1165 |
+
# if news_data and 'market_sentiment' in news_data:
|
| 1166 |
+
# sentiment = news_data.get('market_sentiment', 'neutral')
|
| 1167 |
+
#
|
| 1168 |
+
# if sentiment == 'bullish' and action in ['hold', 'cautious_hold']:
|
| 1169 |
+
# action = 'cautious_buy'
|
| 1170 |
+
# sentiment_adjustment = "(市场氛围积极,可适当提高仓位)"
|
| 1171 |
+
#
|
| 1172 |
+
# elif sentiment == 'bearish' and action in ['buy', 'cautious_buy']:
|
| 1173 |
+
# action = 'hold'
|
| 1174 |
+
# sentiment_adjustment = "(市场氛围悲观,建议等待更好买点)"
|
| 1175 |
+
#
|
| 1176 |
+
# # 4. 技术指标微调
|
| 1177 |
+
# technical_adjustment = ""
|
| 1178 |
+
# if technical_data:
|
| 1179 |
+
# rsi = technical_data.get('RSI', 50)
|
| 1180 |
+
# macd_signal = technical_data.get('MACD_signal', 'neutral')
|
| 1181 |
+
#
|
| 1182 |
+
# # RSI超买超卖调整
|
| 1183 |
+
# if rsi > 80 and action in ['buy', 'strong_buy']:
|
| 1184 |
+
# action = 'hold'
|
| 1185 |
+
# technical_adjustment = "(RSI指标显示超买,建议等待回调)"
|
| 1186 |
+
# elif rsi < 20 and action in ['sell', 'reduce']:
|
| 1187 |
+
# action = 'hold'
|
| 1188 |
+
# technical_adjustment = "(RSI指标显示超卖,可能存在反弹机会)"
|
| 1189 |
+
#
|
| 1190 |
+
# # MACD信号调整
|
| 1191 |
+
# if macd_signal == 'bullish' and action in ['hold', 'cautious_hold']:
|
| 1192 |
+
# action = 'cautious_buy'
|
| 1193 |
+
# if not technical_adjustment:
|
| 1194 |
+
# technical_adjustment = "(MACD显示买入信号)"
|
| 1195 |
+
# elif macd_signal == 'bearish' and action in ['cautious_buy', 'buy']:
|
| 1196 |
+
# action = 'hold'
|
| 1197 |
+
# if not technical_adjustment:
|
| 1198 |
+
# technical_adjustment = "(MACD显示卖出信号)"
|
| 1199 |
+
#
|
| 1200 |
+
# # 5. 根据调整后的action转换为最终建议
|
| 1201 |
+
# action_to_recommendation = {
|
| 1202 |
+
# 'strong_buy': '强烈建议买入',
|
| 1203 |
+
# 'buy': '建议买入',
|
| 1204 |
+
# 'cautious_buy': '谨慎买入',
|
| 1205 |
+
# 'hold': '持观望态度',
|
| 1206 |
+
# 'cautious_hold': '谨慎持有',
|
| 1207 |
+
# 'reduce': '建议减仓',
|
| 1208 |
+
# 'sell': '建议卖出'
|
| 1209 |
+
# }
|
| 1210 |
+
#
|
| 1211 |
+
# final_recommendation = action_to_recommendation.get(action, base_recommendation)
|
| 1212 |
+
#
|
| 1213 |
+
# # 6. 组合所有调整因素
|
| 1214 |
+
# adjustments = " ".join(filter(None, [market_adjustment, sentiment_adjustment, technical_adjustment]))
|
| 1215 |
+
#
|
| 1216 |
+
# if adjustments:
|
| 1217 |
+
# return f"{final_recommendation} {adjustments}"
|
| 1218 |
+
# else:
|
| 1219 |
+
# return final_recommendation
|
| 1220 |
+
#
|
| 1221 |
+
# except Exception as e:
|
| 1222 |
+
# self.logger.error(f"生成投资建议时出错: {str(e)}")
|
| 1223 |
+
# # 出错时返回安全的默认建议
|
| 1224 |
+
# return "无法提供明确建议,请结合多种因素谨慎决策"
|
| 1225 |
+
|
| 1226 |
+
# 原有API:使用 OpenAI 替代 Gemini
|
| 1227 |
+
# def get_ai_analysis(self, df, stock_code):
|
| 1228 |
+
# """使用AI进行分析"""
|
| 1229 |
+
# try:
|
| 1230 |
+
# import openai
|
| 1231 |
+
# import threading
|
| 1232 |
+
# import queue
|
| 1233 |
+
|
| 1234 |
+
# # 设置API密钥和基础URL
|
| 1235 |
+
# openai.api_key = self.openai_api_key
|
| 1236 |
+
# openai.api_base = self.openai_api_url
|
| 1237 |
+
|
| 1238 |
+
# recent_data = df.tail(14).to_dict('records')
|
| 1239 |
+
# technical_summary = {
|
| 1240 |
+
# 'trend': 'upward' if df.iloc[-1]['MA5'] > df.iloc[-1]['MA20'] else 'downward',
|
| 1241 |
+
# 'volatility': f"{df.iloc[-1]['Volatility']:.2f}%",
|
| 1242 |
+
# 'volume_trend': 'increasing' if df.iloc[-1]['Volume_Ratio'] > 1 else 'decreasing',
|
| 1243 |
+
# 'rsi_level': df.iloc[-1]['RSI']
|
| 1244 |
+
# }
|
| 1245 |
+
|
| 1246 |
+
# prompt = f"""分析股票{stock_code}:
|
| 1247 |
+
# 技术指标概要:{technical_summary}
|
| 1248 |
+
# 近14日交易数据:{recent_data}
|
| 1249 |
+
|
| 1250 |
+
# 请提供:
|
| 1251 |
+
# 1.趋势分析(包含支撑位和压力位)
|
| 1252 |
+
# 2.成交量分析及其含义
|
| 1253 |
+
# 3.风险评估(包含波动率分析)
|
| 1254 |
+
# 4.短期和中期目标价位
|
| 1255 |
+
# 5.关键技术位分析
|
| 1256 |
+
# 6.具体交易建议(包含止损位)
|
| 1257 |
+
|
| 1258 |
+
# 请基于技术指标和市场动态进行分析,给出具体数据支持。"""
|
| 1259 |
+
|
| 1260 |
+
# messages = [{"role": "user", "content": prompt}]
|
| 1261 |
+
|
| 1262 |
+
# # 使用线程和队列添加超时控制
|
| 1263 |
+
# result_queue = queue.Queue()
|
| 1264 |
+
|
| 1265 |
+
# def call_api():
|
| 1266 |
+
# try:
|
| 1267 |
+
# response = openai.ChatCompletion.create(
|
| 1268 |
+
# model=self.openai_model,
|
| 1269 |
+
# messages=messages,
|
| 1270 |
+
# temperature=1,
|
| 1271 |
+
# max_tokens=4000,
|
| 1272 |
+
# stream = False
|
| 1273 |
+
# )
|
| 1274 |
+
# result_queue.put(response)
|
| 1275 |
+
# except Exception as e:
|
| 1276 |
+
# result_queue.put(e)
|
| 1277 |
+
|
| 1278 |
+
# # 启动API调用线程
|
| 1279 |
+
# api_thread = threading.Thread(target=call_api)
|
| 1280 |
+
# api_thread.daemon = True
|
| 1281 |
+
# api_thread.start()
|
| 1282 |
+
|
| 1283 |
+
# # 等待结果,最多等待20秒
|
| 1284 |
+
# try:
|
| 1285 |
+
# result = result_queue.get(timeout=20)
|
| 1286 |
+
|
| 1287 |
+
# # 检查结果是否为异常
|
| 1288 |
+
# if isinstance(result, Exception):
|
| 1289 |
+
# raise result
|
| 1290 |
+
|
| 1291 |
+
# # 提取助理回复
|
| 1292 |
+
# assistant_reply = result["choices"][0]["message"]["content"].strip()
|
| 1293 |
+
# return assistant_reply
|
| 1294 |
+
|
| 1295 |
+
# except queue.Empty:
|
| 1296 |
+
# return "AI分析超时,无法获取分析结果。请稍后再试。"
|
| 1297 |
+
# except Exception as e:
|
| 1298 |
+
# return f"AI分析过程中发生错误: {str(e)}"
|
| 1299 |
+
|
| 1300 |
+
# except Exception as e:
|
| 1301 |
+
# self.logger.error(f"AI分析发生错误: {str(e)}")
|
| 1302 |
+
# return "AI分析过程中发生错误,请稍后再试"
|
| 1303 |
+
|
| 1304 |
+
def get_ai_analysis(self, df, stock_code, market_type='A'):
|
| 1305 |
+
"""
|
| 1306 |
+
使用AI进行增强分析
|
| 1307 |
+
结合技术指标、实时新闻和行业信息
|
| 1308 |
+
|
| 1309 |
+
参数:
|
| 1310 |
+
df: 股票历史数据DataFrame
|
| 1311 |
+
stock_code: 股票代码
|
| 1312 |
+
market_type: 市场类型(A/HK/US)
|
| 1313 |
+
|
| 1314 |
+
返回:
|
| 1315 |
+
AI生成的分析报告文本
|
| 1316 |
+
"""
|
| 1317 |
+
try:
|
| 1318 |
+
import openai
|
| 1319 |
+
import threading
|
| 1320 |
+
import queue
|
| 1321 |
+
|
| 1322 |
+
# 设置API密钥和基础URL
|
| 1323 |
+
openai.api_key = self.openai_api_key
|
| 1324 |
+
openai.api_base = self.openai_api_url
|
| 1325 |
+
|
| 1326 |
+
# 1. 获取最近K线数据
|
| 1327 |
+
recent_data = df.tail(20).to_dict('records')
|
| 1328 |
+
|
| 1329 |
+
# 2. 计算技术指标摘要
|
| 1330 |
+
technical_summary = {
|
| 1331 |
+
'trend': 'upward' if df.iloc[-1]['MA5'] > df.iloc[-1]['MA20'] else 'downward',
|
| 1332 |
+
'volatility': f"{df.iloc[-1]['Volatility']:.2f}%",
|
| 1333 |
+
'volume_trend': 'increasing' if df.iloc[-1]['Volume_Ratio'] > 1 else 'decreasing',
|
| 1334 |
+
'rsi_level': df.iloc[-1]['RSI'],
|
| 1335 |
+
'macd_signal': 'bullish' if df.iloc[-1]['MACD'] > df.iloc[-1]['Signal'] else 'bearish',
|
| 1336 |
+
'bb_position': self._calculate_bb_position(df)
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
# 3. 获取支撑压力位
|
| 1340 |
+
sr_levels = self.identify_support_resistance(df)
|
| 1341 |
+
|
| 1342 |
+
# 4. 获取股票基本信息
|
| 1343 |
+
stock_info = self.get_stock_info(stock_code)
|
| 1344 |
+
stock_name = stock_info.get('股票名称', '未知')
|
| 1345 |
+
industry = stock_info.get('行业', '未知')
|
| 1346 |
+
|
| 1347 |
+
# 5. 获取相关新闻和实时信息 - 整合get_stock_news
|
| 1348 |
+
self.logger.info(f"获取 {stock_code} 的相关新闻和市场信息")
|
| 1349 |
+
news_data = self.get_stock_news(stock_code, market_type)
|
| 1350 |
+
|
| 1351 |
+
# 6. 评分分解
|
| 1352 |
+
score = self.calculate_score(df, market_type)
|
| 1353 |
+
score_details = getattr(self, 'score_details', {'total': score})
|
| 1354 |
+
|
| 1355 |
+
# 7. 获取投资建议
|
| 1356 |
+
# 传递技术指标和新闻数据给get_recommendation函数
|
| 1357 |
+
tech_data = {
|
| 1358 |
+
'RSI': technical_summary['rsi_level'],
|
| 1359 |
+
'MACD_signal': technical_summary['macd_signal'],
|
| 1360 |
+
'Volatility': df.iloc[-1]['Volatility']
|
| 1361 |
+
}
|
| 1362 |
+
recommendation = self.get_recommendation(score, market_type, tech_data, news_data)
|
| 1363 |
+
|
| 1364 |
+
# 8. 构建更全面的prompt
|
| 1365 |
+
prompt = f"""作为专业的股票分析师,请对{stock_name}({stock_code})进行全面分析:
|
| 1366 |
+
|
| 1367 |
+
1. 基本信息:
|
| 1368 |
+
- 股票名称: {stock_name}
|
| 1369 |
+
- 股票代码: {stock_code}
|
| 1370 |
+
- 行业: {industry}
|
| 1371 |
+
- 市场类型: {"A股" if market_type == 'A' else "港股" if market_type == 'HK' else "美股"}
|
| 1372 |
+
|
| 1373 |
+
2. 技术指标摘要:
|
| 1374 |
+
- 趋势: {technical_summary['trend']}
|
| 1375 |
+
- 波动率: {technical_summary['volatility']}
|
| 1376 |
+
- 成交量趋势: {technical_summary['volume_trend']}
|
| 1377 |
+
- RSI: {technical_summary['rsi_level']:.2f}
|
| 1378 |
+
- MACD信号: {technical_summary['macd_signal']}
|
| 1379 |
+
- 布林带位置: {technical_summary['bb_position']}
|
| 1380 |
+
|
| 1381 |
+
3. 支撑与压力位:
|
| 1382 |
+
- 短期支撑位: {', '.join([str(level) for level in sr_levels['support_levels']['short_term']])}
|
| 1383 |
+
- 中期支撑位: {', '.join([str(level) for level in sr_levels['support_levels']['medium_term']])}
|
| 1384 |
+
- 短期压力位: {', '.join([str(level) for level in sr_levels['resistance_levels']['short_term']])}
|
| 1385 |
+
- 中期压力位: {', '.join([str(level) for level in sr_levels['resistance_levels']['medium_term']])}
|
| 1386 |
+
|
| 1387 |
+
4. 综合评分: {score_details['total']}分
|
| 1388 |
+
- 趋势评分: {score_details.get('trend', 0)}
|
| 1389 |
+
- 波动率评分: {score_details.get('volatility', 0)}
|
| 1390 |
+
- 技术指标评分: {score_details.get('technical', 0)}
|
| 1391 |
+
- 成交量评分: {score_details.get('volume', 0)}
|
| 1392 |
+
- 动量评分: {score_details.get('momentum', 0)}
|
| 1393 |
+
|
| 1394 |
+
5. 投资建议: {recommendation}"""
|
| 1395 |
+
|
| 1396 |
+
# 检查是否有JSON解析失败的情况
|
| 1397 |
+
if hasattr(self, 'json_match_flag') and not self.json_match_flag and 'original_content' in news_data:
|
| 1398 |
+
# 如果JSON解析失败,直接使用原始内容
|
| 1399 |
+
prompt += f"""
|
| 1400 |
+
|
| 1401 |
+
6. 相关新闻和市场信息:
|
| 1402 |
+
{news_data.get('original_content', '无法获取相关新闻')}
|
| 1403 |
+
"""
|
| 1404 |
+
else:
|
| 1405 |
+
# 正常情况下使用格式化的新闻数据
|
| 1406 |
+
prompt += f"""
|
| 1407 |
+
|
| 1408 |
+
6. 近期相关新闻:
|
| 1409 |
+
{self._format_news_for_prompt(news_data.get('news', []))}
|
| 1410 |
+
|
| 1411 |
+
7. 公司公告:
|
| 1412 |
+
{self._format_announcements_for_prompt(news_data.get('announcements', []))}
|
| 1413 |
+
|
| 1414 |
+
8. 行业动态:
|
| 1415 |
+
{self._format_news_for_prompt(news_data.get('industry_news', []))}
|
| 1416 |
+
|
| 1417 |
+
9. 市场情绪: {news_data.get('market_sentiment', 'neutral')}
|
| 1418 |
+
|
| 1419 |
+
请提供以下内容:
|
| 1420 |
+
1. 技术面分析 - 详细分析价格走势、支撑压力位、主要技术指标的信号
|
| 1421 |
+
2. 行业和市场环境 - 结合新闻和行业动态分析公司所处环境
|
| 1422 |
+
3. 风险因素 - 识别潜在风险点
|
| 1423 |
+
4. 具体交易策略 - 给出明确的买入/卖出建议,包括入场点、止损位和目标价位
|
| 1424 |
+
5. 短期(1周)、中期(1-3个月)和长期(半年)展望
|
| 1425 |
+
|
| 1426 |
+
请基于数据给出客观分析,不要过度乐观或悲观。分析应该包含具体数据和百分比,避免模糊表述。
|
| 1427 |
+
"""
|
| 1428 |
+
|
| 1429 |
+
messages = [{"role": "user", "content": prompt}]
|
| 1430 |
+
|
| 1431 |
+
# 使用线程和队列添加超时控制
|
| 1432 |
+
result_queue = queue.Queue()
|
| 1433 |
+
|
| 1434 |
+
def call_api():
|
| 1435 |
+
try:
|
| 1436 |
+
response = openai.ChatCompletion.create(
|
| 1437 |
+
model=self.openai_model,
|
| 1438 |
+
messages=messages,
|
| 1439 |
+
temperature=0.8,
|
| 1440 |
+
max_tokens=4000,
|
| 1441 |
+
stream=False,
|
| 1442 |
+
timeout=180
|
| 1443 |
+
)
|
| 1444 |
+
result_queue.put(response)
|
| 1445 |
+
except Exception as e:
|
| 1446 |
+
result_queue.put(e)
|
| 1447 |
+
|
| 1448 |
+
# 启动API调用线程
|
| 1449 |
+
api_thread = threading.Thread(target=call_api)
|
| 1450 |
+
api_thread.daemon = True
|
| 1451 |
+
api_thread.start()
|
| 1452 |
+
|
| 1453 |
+
# 等待结果,最多等待240秒
|
| 1454 |
+
try:
|
| 1455 |
+
result = result_queue.get(timeout=240)
|
| 1456 |
+
|
| 1457 |
+
# 检查结果是否为异常
|
| 1458 |
+
if isinstance(result, Exception):
|
| 1459 |
+
raise result
|
| 1460 |
+
|
| 1461 |
+
# 提取助理回复
|
| 1462 |
+
assistant_reply = result["choices"][0]["message"]["content"].strip()
|
| 1463 |
+
return assistant_reply
|
| 1464 |
+
|
| 1465 |
+
except queue.Empty:
|
| 1466 |
+
return "AI分析超时,无法获取分析结果。请稍后再试。"
|
| 1467 |
+
except Exception as e:
|
| 1468 |
+
return f"AI分析过程中发生错误: {str(e)}"
|
| 1469 |
+
|
| 1470 |
+
except Exception as e:
|
| 1471 |
+
self.logger.error(f"AI分析发生错误: {str(e)}")
|
| 1472 |
+
return f"AI分析过程中发生错误,请稍后再试。错误信息: {str(e)}"
|
| 1473 |
+
|
| 1474 |
+
def _calculate_bb_position(self, df):
|
| 1475 |
+
"""计算价格在布林带中的位置"""
|
| 1476 |
+
latest = df.iloc[-1]
|
| 1477 |
+
bb_width = latest['BB_upper'] - latest['BB_lower']
|
| 1478 |
+
if bb_width == 0:
|
| 1479 |
+
return "middle"
|
| 1480 |
+
|
| 1481 |
+
position = (latest['close'] - latest['BB_lower']) / bb_width
|
| 1482 |
+
|
| 1483 |
+
if position < 0.2:
|
| 1484 |
+
return "near lower band (potential oversold)"
|
| 1485 |
+
elif position < 0.4:
|
| 1486 |
+
return "below middle band"
|
| 1487 |
+
elif position < 0.6:
|
| 1488 |
+
return "near middle band"
|
| 1489 |
+
elif position < 0.8:
|
| 1490 |
+
return "above middle band"
|
| 1491 |
+
else:
|
| 1492 |
+
return "near upper band (potential overbought)"
|
| 1493 |
+
|
| 1494 |
+
def _format_news_for_prompt(self, news_list):
|
| 1495 |
+
"""格式化新闻列表为prompt字符串"""
|
| 1496 |
+
if not news_list:
|
| 1497 |
+
return " 无最新相关新闻"
|
| 1498 |
+
|
| 1499 |
+
formatted = ""
|
| 1500 |
+
for i, news in enumerate(news_list[:3]): # 最多显示3条
|
| 1501 |
+
date = news.get('date', '')
|
| 1502 |
+
title = news.get('title', '')
|
| 1503 |
+
source = news.get('source', '')
|
| 1504 |
+
formatted += f" {i + 1}. [{date}] {title} (来源: {source})\n"
|
| 1505 |
+
|
| 1506 |
+
return formatted
|
| 1507 |
+
|
| 1508 |
+
def _format_announcements_for_prompt(self, announcements):
|
| 1509 |
+
"""格式化公告列表为prompt字符串"""
|
| 1510 |
+
if not announcements:
|
| 1511 |
+
return " 无最新公告"
|
| 1512 |
+
|
| 1513 |
+
formatted = ""
|
| 1514 |
+
for i, ann in enumerate(announcements[:3]): # 最多显示3条
|
| 1515 |
+
date = ann.get('date', '')
|
| 1516 |
+
title = ann.get('title', '')
|
| 1517 |
+
type_ = ann.get('type', '')
|
| 1518 |
+
formatted += f" {i + 1}. [{date}] {title} (类型: {type_})\n"
|
| 1519 |
+
|
| 1520 |
+
return formatted
|
| 1521 |
+
|
| 1522 |
+
# 原有API:保持接口不变
|
| 1523 |
+
def analyze_stock(self, stock_code, market_type='A'):
|
| 1524 |
+
"""分析单个股票"""
|
| 1525 |
+
try:
|
| 1526 |
+
# self.clear_cache(stock_code, market_type)
|
| 1527 |
+
# 获取股票数据
|
| 1528 |
+
df = self.get_stock_data(stock_code, market_type)
|
| 1529 |
+
self.logger.info(f"获取股票数据完成")
|
| 1530 |
+
# 计算技术指标
|
| 1531 |
+
df = self.calculate_indicators(df)
|
| 1532 |
+
self.logger.info(f"计算技术指标完成")
|
| 1533 |
+
# 评分系统
|
| 1534 |
+
score = self.calculate_score(df)
|
| 1535 |
+
self.logger.info(f"评分系统完成")
|
| 1536 |
+
# 获取最新数据
|
| 1537 |
+
latest = df.iloc[-1]
|
| 1538 |
+
prev = df.iloc[-2]
|
| 1539 |
+
|
| 1540 |
+
# 获取基本信息
|
| 1541 |
+
stock_info = self.get_stock_info(stock_code)
|
| 1542 |
+
stock_name = stock_info.get('股票名称', '未知')
|
| 1543 |
+
industry = stock_info.get('行业', '未知')
|
| 1544 |
+
|
| 1545 |
+
# 生成报告(保持原有格式)
|
| 1546 |
+
report = {
|
| 1547 |
+
'stock_code': stock_code,
|
| 1548 |
+
'stock_name': stock_name,
|
| 1549 |
+
'industry': industry,
|
| 1550 |
+
'analysis_date': datetime.now().strftime('%Y-%m-%d'),
|
| 1551 |
+
'score': score,
|
| 1552 |
+
'price': latest['close'],
|
| 1553 |
+
'price_change': (latest['close'] - prev['close']) / prev['close'] * 100,
|
| 1554 |
+
'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
|
| 1555 |
+
'rsi': latest['RSI'],
|
| 1556 |
+
'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
|
| 1557 |
+
'volume_status': '放量' if latest['Volume_Ratio'] > 1.5 else '平量',
|
| 1558 |
+
'recommendation': self.get_recommendation(score),
|
| 1559 |
+
'ai_analysis': self.get_ai_analysis(df, stock_code)
|
| 1560 |
+
}
|
| 1561 |
+
|
| 1562 |
+
return report
|
| 1563 |
+
|
| 1564 |
+
except Exception as e:
|
| 1565 |
+
self.logger.error(f"分析股票时出错: {str(e)}")
|
| 1566 |
+
raise
|
| 1567 |
+
|
| 1568 |
+
# 原有API:保持接口不变
|
| 1569 |
+
def scan_market(self, stock_list, min_score=60, market_type='A'):
|
| 1570 |
+
"""扫描市场,寻找符合条件的股票"""
|
| 1571 |
+
recommendations = []
|
| 1572 |
+
total_stocks = len(stock_list)
|
| 1573 |
+
|
| 1574 |
+
self.logger.info(f"开始市场扫描,共 {total_stocks} 只股票")
|
| 1575 |
+
start_time = time.time()
|
| 1576 |
+
processed = 0
|
| 1577 |
+
|
| 1578 |
+
# 批量处理,减少日志输出
|
| 1579 |
+
batch_size = 10
|
| 1580 |
+
for i in range(0, total_stocks, batch_size):
|
| 1581 |
+
batch = stock_list[i:i + batch_size]
|
| 1582 |
+
batch_results = []
|
| 1583 |
+
|
| 1584 |
+
for stock_code in batch:
|
| 1585 |
+
try:
|
| 1586 |
+
# 使用简化版分析以加快速度
|
| 1587 |
+
report = self.quick_analyze_stock(stock_code, market_type)
|
| 1588 |
+
if report['score'] >= min_score:
|
| 1589 |
+
batch_results.append(report)
|
| 1590 |
+
except Exception as e:
|
| 1591 |
+
self.logger.error(f"分析股票 {stock_code} 时出错: {str(e)}")
|
| 1592 |
+
continue
|
| 1593 |
+
|
| 1594 |
+
# 添加批处理结果
|
| 1595 |
+
recommendations.extend(batch_results)
|
| 1596 |
+
|
| 1597 |
+
# 更新处理进度
|
| 1598 |
+
processed += len(batch)
|
| 1599 |
+
elapsed = time.time() - start_time
|
| 1600 |
+
remaining = (elapsed / processed) * (total_stocks - processed) if processed > 0 else 0
|
| 1601 |
+
|
| 1602 |
+
self.logger.info(
|
| 1603 |
+
f"已处理 {processed}/{total_stocks} 只股票,耗时 {elapsed:.1f}秒,预计剩余 {remaining:.1f}秒")
|
| 1604 |
+
|
| 1605 |
+
# 按得分排序
|
| 1606 |
+
recommendations.sort(key=lambda x: x['score'], reverse=True)
|
| 1607 |
+
|
| 1608 |
+
total_time = time.time() - start_time
|
| 1609 |
+
self.logger.info(
|
| 1610 |
+
f"市场扫描完成,共分析 {total_stocks} 只股票,找到 {len(recommendations)} 只符合条件的股票,总耗时 {total_time:.1f}秒")
|
| 1611 |
+
|
| 1612 |
+
return recommendations
|
| 1613 |
+
|
| 1614 |
+
# def quick_analyze_stock(self, stock_code, market_type='A'):
|
| 1615 |
+
# """快速分析股票,用于市场扫描"""
|
| 1616 |
+
# try:
|
| 1617 |
+
# # 获取股票数据
|
| 1618 |
+
# df = self.get_stock_data(stock_code, market_type)
|
| 1619 |
+
|
| 1620 |
+
# # 计算技术指标
|
| 1621 |
+
# df = self.calculate_indicators(df)
|
| 1622 |
+
|
| 1623 |
+
# # 简化评分计算
|
| 1624 |
+
# score = self.calculate_score(df)
|
| 1625 |
+
|
| 1626 |
+
# # 获取最新数据
|
| 1627 |
+
# latest = df.iloc[-1]
|
| 1628 |
+
# prev = df.iloc[-2] if len(df) > 1 else latest
|
| 1629 |
+
|
| 1630 |
+
# # 尝试获取股票名称和行业
|
| 1631 |
+
# try:
|
| 1632 |
+
# stock_info = self.get_stock_info(stock_code)
|
| 1633 |
+
# stock_name = stock_info.get('股票名称', '未知')
|
| 1634 |
+
# industry = stock_info.get('行业', '未知')
|
| 1635 |
+
# except:
|
| 1636 |
+
# stock_name = '未知'
|
| 1637 |
+
# industry = '未知'
|
| 1638 |
+
|
| 1639 |
+
# # 生成简化报告
|
| 1640 |
+
# report = {
|
| 1641 |
+
# 'stock_code': stock_code,
|
| 1642 |
+
# 'stock_name': stock_name,
|
| 1643 |
+
# 'industry': industry,
|
| 1644 |
+
# 'analysis_date': datetime.now().strftime('%Y-%m-%d'),
|
| 1645 |
+
# 'score': score,
|
| 1646 |
+
# 'price': float(latest['close']),
|
| 1647 |
+
# 'price_change': float((latest['close'] - prev['close']) / prev['close'] * 100),
|
| 1648 |
+
# 'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
|
| 1649 |
+
# 'rsi': float(latest['RSI']),
|
| 1650 |
+
# 'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
|
| 1651 |
+
# 'volume_status': '放量' if latest['Volume_Ratio'] > 1.5 else '平量',
|
| 1652 |
+
# 'recommendation': self.get_recommendation(score)
|
| 1653 |
+
# }
|
| 1654 |
+
|
| 1655 |
+
# return report
|
| 1656 |
+
# except Exception as e:
|
| 1657 |
+
# self.logger.error(f"快速分析股票 {stock_code} 时出错: {str(e)}")
|
| 1658 |
+
# raise
|
| 1659 |
+
|
| 1660 |
+
def quick_analyze_stock(self, stock_code, market_type='A'):
|
| 1661 |
+
"""快速分析股票,用于市场扫描"""
|
| 1662 |
+
try:
|
| 1663 |
+
# 获取股票数据
|
| 1664 |
+
df = self.get_stock_data(stock_code, market_type)
|
| 1665 |
+
|
| 1666 |
+
# 计算技术指标
|
| 1667 |
+
df = self.calculate_indicators(df)
|
| 1668 |
+
|
| 1669 |
+
# 简化评分计算
|
| 1670 |
+
score = self.calculate_score(df)
|
| 1671 |
+
|
| 1672 |
+
# 获取最新数据
|
| 1673 |
+
latest = df.iloc[-1]
|
| 1674 |
+
prev = df.iloc[-2] if len(df) > 1 else latest
|
| 1675 |
+
|
| 1676 |
+
# 先获取股票信息再生成报告
|
| 1677 |
+
try:
|
| 1678 |
+
stock_info = self.get_stock_info(stock_code)
|
| 1679 |
+
stock_name = stock_info.get('股票名称', '未知')
|
| 1680 |
+
industry = stock_info.get('行业', '未知')
|
| 1681 |
+
|
| 1682 |
+
# 添加日志
|
| 1683 |
+
self.logger.info(f"股票 {stock_code} 信息: 名称={stock_name}, 行业={industry}")
|
| 1684 |
+
except Exception as e:
|
| 1685 |
+
self.logger.error(f"获取股票 {stock_code} 信息时出错: {str(e)}")
|
| 1686 |
+
stock_name = '未知'
|
| 1687 |
+
industry = '未知'
|
| 1688 |
+
|
| 1689 |
+
# 生成简化报告
|
| 1690 |
+
report = {
|
| 1691 |
+
'stock_code': stock_code,
|
| 1692 |
+
'stock_name': stock_name,
|
| 1693 |
+
'industry': industry,
|
| 1694 |
+
'analysis_date': datetime.now().strftime('%Y-%m-%d'),
|
| 1695 |
+
'score': score,
|
| 1696 |
+
'price': float(latest['close']),
|
| 1697 |
+
'price_change': float((latest['close'] - prev['close']) / prev['close'] * 100),
|
| 1698 |
+
'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
|
| 1699 |
+
'rsi': float(latest['RSI']),
|
| 1700 |
+
'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
|
| 1701 |
+
'volume_status': 'HIGH' if latest['Volume_Ratio'] > 1.5 else 'NORMAL',
|
| 1702 |
+
'recommendation': self.get_recommendation(score)
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
return report
|
| 1706 |
+
except Exception as e:
|
| 1707 |
+
self.logger.error(f"快速分析股票 {stock_code} 时出错: {str(e)}")
|
| 1708 |
+
raise
|
| 1709 |
+
|
| 1710 |
+
# ======================== 新增功能 ========================#
|
| 1711 |
+
|
| 1712 |
+
def get_stock_info(self, stock_code):
|
| 1713 |
+
"""获取股票基本信息"""
|
| 1714 |
+
import akshare as ak
|
| 1715 |
+
|
| 1716 |
+
cache_key = f"{stock_code}_info"
|
| 1717 |
+
if cache_key in self.data_cache:
|
| 1718 |
+
return self.data_cache[cache_key]
|
| 1719 |
+
|
| 1720 |
+
try:
|
| 1721 |
+
# 获取A股股票基本信息
|
| 1722 |
+
stock_info = ak.stock_individual_info_em(symbol=stock_code)
|
| 1723 |
+
|
| 1724 |
+
# 修改:使用列名而不是索引访问数据
|
| 1725 |
+
info_dict = {}
|
| 1726 |
+
for _, row in stock_info.iterrows():
|
| 1727 |
+
# 使用iloc安全地获取数据
|
| 1728 |
+
if len(row) >= 2: # 确保有至少两列
|
| 1729 |
+
info_dict[row.iloc[0]] = row.iloc[1]
|
| 1730 |
+
|
| 1731 |
+
# 获取股票名称
|
| 1732 |
+
try:
|
| 1733 |
+
stock_name = ak.stock_info_a_code_name()
|
| 1734 |
+
|
| 1735 |
+
# 检查数据框是否包含预期的列
|
| 1736 |
+
if '代码' in stock_name.columns and '名称' in stock_name.columns:
|
| 1737 |
+
# 尝试找到匹配的股票代码
|
| 1738 |
+
matched_stocks = stock_name[stock_name['代码'] == stock_code]
|
| 1739 |
+
if not matched_stocks.empty:
|
| 1740 |
+
name = matched_stocks['名称'].values[0]
|
| 1741 |
+
else:
|
| 1742 |
+
self.logger.warning(f"未找到股票代码 {stock_code} 的名称信息")
|
| 1743 |
+
name = "未知"
|
| 1744 |
+
else:
|
| 1745 |
+
# 尝试使用不同的列名
|
| 1746 |
+
possible_code_columns = ['代码', 'code', 'symbol', '股票代码', 'stock_code']
|
| 1747 |
+
possible_name_columns = ['名称', 'name', '股票名称', 'stock_name']
|
| 1748 |
+
|
| 1749 |
+
code_col = next((col for col in possible_code_columns if col in stock_name.columns), None)
|
| 1750 |
+
name_col = next((col for col in possible_name_columns if col in stock_name.columns), None)
|
| 1751 |
+
|
| 1752 |
+
if code_col and name_col:
|
| 1753 |
+
matched_stocks = stock_name[stock_name[code_col] == stock_code]
|
| 1754 |
+
if not matched_stocks.empty:
|
| 1755 |
+
name = matched_stocks[name_col].values[0]
|
| 1756 |
+
else:
|
| 1757 |
+
name = "未知"
|
| 1758 |
+
else:
|
| 1759 |
+
self.logger.warning(f"股票信息DataFrame结构不符合预期: {stock_name.columns.tolist()}")
|
| 1760 |
+
name = "未知"
|
| 1761 |
+
except Exception as e:
|
| 1762 |
+
self.logger.error(f"获取股票名称时出错: {str(e)}")
|
| 1763 |
+
name = "未知"
|
| 1764 |
+
|
| 1765 |
+
info_dict['股票名称'] = name
|
| 1766 |
+
|
| 1767 |
+
# 确保基本字段存在
|
| 1768 |
+
if '行业' not in info_dict:
|
| 1769 |
+
info_dict['行业'] = "未知"
|
| 1770 |
+
if '地区' not in info_dict:
|
| 1771 |
+
info_dict['地区'] = "未知"
|
| 1772 |
+
|
| 1773 |
+
# 增加更多日志来调试问题
|
| 1774 |
+
self.logger.info(f"获取到股票信息: 名称={name}, 行业={info_dict.get('行业', '未知')}")
|
| 1775 |
+
|
| 1776 |
+
self.data_cache[cache_key] = info_dict
|
| 1777 |
+
return info_dict
|
| 1778 |
+
except Exception as e:
|
| 1779 |
+
self.logger.error(f"获取股票信息失败: {str(e)}")
|
| 1780 |
+
return {"股票名称": "未知", "行业": "未知", "地区": "未知"}
|
| 1781 |
+
|
| 1782 |
+
def identify_support_resistance(self, df):
|
| 1783 |
+
"""识别支撑位和压力位"""
|
| 1784 |
+
latest_price = df['close'].iloc[-1]
|
| 1785 |
+
|
| 1786 |
+
# 使用布林带作为支撑压力参考
|
| 1787 |
+
support_levels = [df['BB_lower'].iloc[-1]]
|
| 1788 |
+
resistance_levels = [df['BB_upper'].iloc[-1]]
|
| 1789 |
+
|
| 1790 |
+
# 添加主要均线作为支撑压力
|
| 1791 |
+
if latest_price < df['MA5'].iloc[-1]:
|
| 1792 |
+
resistance_levels.append(df['MA5'].iloc[-1])
|
| 1793 |
+
else:
|
| 1794 |
+
support_levels.append(df['MA5'].iloc[-1])
|
| 1795 |
+
|
| 1796 |
+
if latest_price < df['MA20'].iloc[-1]:
|
| 1797 |
+
resistance_levels.append(df['MA20'].iloc[-1])
|
| 1798 |
+
else:
|
| 1799 |
+
support_levels.append(df['MA20'].iloc[-1])
|
| 1800 |
+
|
| 1801 |
+
# 添加整数关口
|
| 1802 |
+
price_digits = len(str(int(latest_price)))
|
| 1803 |
+
base = 10 ** (price_digits - 1)
|
| 1804 |
+
|
| 1805 |
+
lower_integer = math.floor(latest_price / base) * base
|
| 1806 |
+
upper_integer = math.ceil(latest_price / base) * base
|
| 1807 |
+
|
| 1808 |
+
if lower_integer < latest_price:
|
| 1809 |
+
support_levels.append(lower_integer)
|
| 1810 |
+
if upper_integer > latest_price:
|
| 1811 |
+
resistance_levels.append(upper_integer)
|
| 1812 |
+
|
| 1813 |
+
# 排序并格式化
|
| 1814 |
+
support_levels = sorted(set([round(x, 2) for x in support_levels if x < latest_price]), reverse=True)
|
| 1815 |
+
resistance_levels = sorted(set([round(x, 2) for x in resistance_levels if x > latest_price]))
|
| 1816 |
+
|
| 1817 |
+
# 分类为短期和中期
|
| 1818 |
+
short_term_support = support_levels[:1] if support_levels else []
|
| 1819 |
+
medium_term_support = support_levels[1:2] if len(support_levels) > 1 else []
|
| 1820 |
+
short_term_resistance = resistance_levels[:1] if resistance_levels else []
|
| 1821 |
+
medium_term_resistance = resistance_levels[1:2] if len(resistance_levels) > 1 else []
|
| 1822 |
+
|
| 1823 |
+
return {
|
| 1824 |
+
'support_levels': {
|
| 1825 |
+
'short_term': short_term_support,
|
| 1826 |
+
'medium_term': medium_term_support
|
| 1827 |
+
},
|
| 1828 |
+
'resistance_levels': {
|
| 1829 |
+
'short_term': short_term_resistance,
|
| 1830 |
+
'medium_term': medium_term_resistance
|
| 1831 |
+
}
|
| 1832 |
+
}
|
| 1833 |
+
|
| 1834 |
+
def calculate_technical_score(self, df):
|
| 1835 |
+
"""计算技术面评分 (0-40分)"""
|
| 1836 |
+
try:
|
| 1837 |
+
score = 0
|
| 1838 |
+
# 确保有足够的数据
|
| 1839 |
+
if len(df) < 2:
|
| 1840 |
+
self.logger.warning("数据不足,无法计算技术面评分")
|
| 1841 |
+
return {'total': 0, 'trend': 0, 'indicators': 0, 'support_resistance': 0, 'volatility_volume': 0}
|
| 1842 |
+
|
| 1843 |
+
latest = df.iloc[-1]
|
| 1844 |
+
prev = df.iloc[-2] # 获取前一个时间点的数据
|
| 1845 |
+
prev_close = prev['close']
|
| 1846 |
+
|
| 1847 |
+
# 1. 趋势分析 (0-10分)
|
| 1848 |
+
trend_score = 0
|
| 1849 |
+
|
| 1850 |
+
# 均线排列情况
|
| 1851 |
+
if latest['MA5'] > latest['MA20'] > latest['MA60']: # 多头排列
|
| 1852 |
+
trend_score += 5
|
| 1853 |
+
elif latest['MA5'] < latest['MA20'] < latest['MA60']: # 空头排列
|
| 1854 |
+
trend_score = 0
|
| 1855 |
+
else: # 交叉状态
|
| 1856 |
+
if latest['MA5'] > latest['MA20']:
|
| 1857 |
+
trend_score += 3
|
| 1858 |
+
if latest['MA20'] > latest['MA60']:
|
| 1859 |
+
trend_score += 2
|
| 1860 |
+
|
| 1861 |
+
# 价格与均线关系
|
| 1862 |
+
if latest['close'] > latest['MA5']:
|
| 1863 |
+
trend_score += 3
|
| 1864 |
+
elif latest['close'] > latest['MA20']:
|
| 1865 |
+
trend_score += 2
|
| 1866 |
+
|
| 1867 |
+
# 限制最大值
|
| 1868 |
+
trend_score = min(trend_score, 10)
|
| 1869 |
+
score += trend_score
|
| 1870 |
+
|
| 1871 |
+
# 2. 技术指标分析 (0-10分)
|
| 1872 |
+
indicator_score = 0
|
| 1873 |
+
|
| 1874 |
+
# RSI
|
| 1875 |
+
if 40 <= latest['RSI'] <= 60: # 中性
|
| 1876 |
+
indicator_score += 2
|
| 1877 |
+
elif 30 <= latest['RSI'] < 40 or 60 < latest['RSI'] <= 70: # 边缘区域
|
| 1878 |
+
indicator_score += 4
|
| 1879 |
+
elif latest['RSI'] < 30: # 超卖
|
| 1880 |
+
indicator_score += 5
|
| 1881 |
+
elif latest['RSI'] > 70: # 超买
|
| 1882 |
+
indicator_score += 0
|
| 1883 |
+
|
| 1884 |
+
# MACD
|
| 1885 |
+
if latest['MACD'] > latest['Signal']: # MACD金叉或在零轴上方
|
| 1886 |
+
indicator_score += 3
|
| 1887 |
+
else:
|
| 1888 |
+
# 修复:比较当前和前一个时间点的MACD柱状图值
|
| 1889 |
+
if latest['MACD_hist'] > prev['MACD_hist']: # 柱状图上升
|
| 1890 |
+
indicator_score += 1
|
| 1891 |
+
|
| 1892 |
+
# 限制最大值和最小值
|
| 1893 |
+
indicator_score = max(0, min(indicator_score, 10))
|
| 1894 |
+
score += indicator_score
|
| 1895 |
+
|
| 1896 |
+
# 3. 支撑压力位分析 (0-10分)
|
| 1897 |
+
sr_score = 0
|
| 1898 |
+
|
| 1899 |
+
# 识别支撑位和压力位
|
| 1900 |
+
middle_price = latest['close']
|
| 1901 |
+
upper_band = latest['BB_upper']
|
| 1902 |
+
lower_band = latest['BB_lower']
|
| 1903 |
+
|
| 1904 |
+
# 距离布林带上下轨的距离
|
| 1905 |
+
upper_distance = (upper_band - middle_price) / middle_price * 100
|
| 1906 |
+
lower_distance = (middle_price - lower_band) / middle_price * 100
|
| 1907 |
+
|
| 1908 |
+
if lower_distance < 2: # 接近下轨
|
| 1909 |
+
sr_score += 5
|
| 1910 |
+
elif lower_distance < 5:
|
| 1911 |
+
sr_score += 3
|
| 1912 |
+
|
| 1913 |
+
if upper_distance > 5: # 距上轨较远
|
| 1914 |
+
sr_score += 5
|
| 1915 |
+
elif upper_distance > 2:
|
| 1916 |
+
sr_score += 2
|
| 1917 |
+
|
| 1918 |
+
# 限制最大值
|
| 1919 |
+
sr_score = min(sr_score, 10)
|
| 1920 |
+
score += sr_score
|
| 1921 |
+
|
| 1922 |
+
# 4. 波动性和成交量分析 (0-10分)
|
| 1923 |
+
vol_score = 0
|
| 1924 |
+
|
| 1925 |
+
# 波动率分析
|
| 1926 |
+
if latest['Volatility'] < 2: # 低波动率
|
| 1927 |
+
vol_score += 3
|
| 1928 |
+
elif latest['Volatility'] < 4: # 中等波动率
|
| 1929 |
+
vol_score += 2
|
| 1930 |
+
|
| 1931 |
+
# 成交量分析
|
| 1932 |
+
if 'Volume_Ratio' in df.columns:
|
| 1933 |
+
if latest['Volume_Ratio'] > 1.5 and latest['close'] > prev_close: # 放量上涨
|
| 1934 |
+
vol_score += 4
|
| 1935 |
+
elif latest['Volume_Ratio'] < 0.8 and latest['close'] < prev_close: # 缩量下跌
|
| 1936 |
+
vol_score += 3
|
| 1937 |
+
elif latest['Volume_Ratio'] > 1 and latest['close'] > prev_close: # 普通放量上涨
|
| 1938 |
+
vol_score += 2
|
| 1939 |
+
|
| 1940 |
+
# 限制最大值
|
| 1941 |
+
vol_score = min(vol_score, 10)
|
| 1942 |
+
score += vol_score
|
| 1943 |
+
|
| 1944 |
+
# 保存各个维度的分数
|
| 1945 |
+
technical_scores = {
|
| 1946 |
+
'total': score,
|
| 1947 |
+
'trend': trend_score,
|
| 1948 |
+
'indicators': indicator_score,
|
| 1949 |
+
'support_resistance': sr_score,
|
| 1950 |
+
'volatility_volume': vol_score
|
| 1951 |
+
}
|
| 1952 |
+
|
| 1953 |
+
return technical_scores
|
| 1954 |
+
|
| 1955 |
+
except Exception as e:
|
| 1956 |
+
self.logger.error(f"计算技术面评分时出错: {str(e)}")
|
| 1957 |
+
self.logger.error(f"错误详情: {traceback.format_exc()}")
|
| 1958 |
+
return {'total': 0, 'trend': 0, 'indicators': 0, 'support_resistance': 0, 'volatility_volume': 0}
|
| 1959 |
+
|
| 1960 |
+
def perform_enhanced_analysis(self, stock_code, market_type='A'):
|
| 1961 |
+
"""执行增强版分析"""
|
| 1962 |
+
try:
|
| 1963 |
+
# 记录开始时间,便于性能分析
|
| 1964 |
+
start_time = time.time()
|
| 1965 |
+
self.logger.info(f"开始执行股票 {stock_code} 的增强分析")
|
| 1966 |
+
|
| 1967 |
+
# 获取股票数据
|
| 1968 |
+
df = self.get_stock_data(stock_code, market_type)
|
| 1969 |
+
data_time = time.time()
|
| 1970 |
+
self.logger.info(f"获取股票数据耗时: {data_time - start_time:.2f}秒")
|
| 1971 |
+
|
| 1972 |
+
# 计算技术指标
|
| 1973 |
+
df = self.calculate_indicators(df)
|
| 1974 |
+
indicator_time = time.time()
|
| 1975 |
+
self.logger.info(f"计算技术指标耗时: {indicator_time - data_time:.2f}秒")
|
| 1976 |
+
|
| 1977 |
+
# 获取最新数据
|
| 1978 |
+
latest = df.iloc[-1]
|
| 1979 |
+
prev = df.iloc[-2] if len(df) > 1 else latest
|
| 1980 |
+
|
| 1981 |
+
# 获取支撑压力位
|
| 1982 |
+
sr_levels = self.identify_support_resistance(df)
|
| 1983 |
+
|
| 1984 |
+
# 计算技术面评分
|
| 1985 |
+
technical_score = self.calculate_technical_score(df)
|
| 1986 |
+
|
| 1987 |
+
# 获取股票信息
|
| 1988 |
+
stock_info = self.get_stock_info(stock_code)
|
| 1989 |
+
|
| 1990 |
+
# 确保technical_score包含必要的字段
|
| 1991 |
+
if 'total' not in technical_score:
|
| 1992 |
+
technical_score['total'] = 0
|
| 1993 |
+
|
| 1994 |
+
# 生成增强版报告
|
| 1995 |
+
enhanced_report = {
|
| 1996 |
+
'basic_info': {
|
| 1997 |
+
'stock_code': stock_code,
|
| 1998 |
+
'stock_name': stock_info.get('股票名称', '未知'),
|
| 1999 |
+
'industry': stock_info.get('行业', '未知'),
|
| 2000 |
+
'analysis_date': datetime.now().strftime('%Y-%m-%d')
|
| 2001 |
+
},
|
| 2002 |
+
'price_data': {
|
| 2003 |
+
'current_price': float(latest['close']), # 确保是Python原生类型
|
| 2004 |
+
'price_change': float((latest['close'] - prev['close']) / prev['close'] * 100),
|
| 2005 |
+
'price_change_value': float(latest['close'] - prev['close'])
|
| 2006 |
+
},
|
| 2007 |
+
'technical_analysis': {
|
| 2008 |
+
'trend': {
|
| 2009 |
+
'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
|
| 2010 |
+
'ma_status': "多头排列" if latest['MA5'] > latest['MA20'] > latest['MA60'] else
|
| 2011 |
+
"空头排列" if latest['MA5'] < latest['MA20'] < latest['MA60'] else
|
| 2012 |
+
"交叉状态",
|
| 2013 |
+
'ma_values': {
|
| 2014 |
+
'ma5': float(latest['MA5']),
|
| 2015 |
+
'ma20': float(latest['MA20']),
|
| 2016 |
+
'ma60': float(latest['MA60'])
|
| 2017 |
+
}
|
| 2018 |
+
},
|
| 2019 |
+
'indicators': {
|
| 2020 |
+
# 确保所有指标都存在并是原生类型
|
| 2021 |
+
'rsi': float(latest['RSI']) if 'RSI' in latest else 50.0,
|
| 2022 |
+
'macd': float(latest['MACD']) if 'MACD' in latest else 0.0,
|
| 2023 |
+
'macd_signal': float(latest['Signal']) if 'Signal' in latest else 0.0,
|
| 2024 |
+
'macd_histogram': float(latest['MACD_hist']) if 'MACD_hist' in latest else 0.0,
|
| 2025 |
+
'volatility': float(latest['Volatility']) if 'Volatility' in latest else 0.0
|
| 2026 |
+
},
|
| 2027 |
+
'volume': {
|
| 2028 |
+
'current_volume': float(latest['volume']) if 'volume' in latest else 0.0,
|
| 2029 |
+
'volume_ratio': float(latest['Volume_Ratio']) if 'Volume_Ratio' in latest else 1.0,
|
| 2030 |
+
'volume_status': '放量' if 'Volume_Ratio' in latest and latest['Volume_Ratio'] > 1.5 else '平量'
|
| 2031 |
+
},
|
| 2032 |
+
'support_resistance': sr_levels
|
| 2033 |
+
},
|
| 2034 |
+
'scores': technical_score,
|
| 2035 |
+
'recommendation': {
|
| 2036 |
+
'action': self.get_recommendation(technical_score['total']),
|
| 2037 |
+
'key_points': []
|
| 2038 |
+
},
|
| 2039 |
+
'ai_analysis': self.get_ai_analysis(df, stock_code)
|
| 2040 |
+
}
|
| 2041 |
+
|
| 2042 |
+
# 最后检查并修复报告结构
|
| 2043 |
+
self._validate_and_fix_report(enhanced_report)
|
| 2044 |
+
|
| 2045 |
+
# 在函数结束时记录总耗时
|
| 2046 |
+
end_time = time.time()
|
| 2047 |
+
self.logger.info(f"执行增强分析总耗时: {end_time - start_time:.2f}秒")
|
| 2048 |
+
|
| 2049 |
+
return enhanced_report
|
| 2050 |
+
|
| 2051 |
+
except Exception as e:
|
| 2052 |
+
self.logger.error(f"执行增强版分析时出错: {str(e)}")
|
| 2053 |
+
self.logger.error(traceback.format_exc())
|
| 2054 |
+
|
| 2055 |
+
# 返回基础错误报告
|
| 2056 |
+
return {
|
| 2057 |
+
'basic_info': {
|
| 2058 |
+
'stock_code': stock_code,
|
| 2059 |
+
'stock_name': '分析失败',
|
| 2060 |
+
'industry': '未知',
|
| 2061 |
+
'analysis_date': datetime.now().strftime('%Y-%m-%d')
|
| 2062 |
+
},
|
| 2063 |
+
'price_data': {
|
| 2064 |
+
'current_price': 0.0,
|
| 2065 |
+
'price_change': 0.0,
|
| 2066 |
+
'price_change_value': 0.0
|
| 2067 |
+
},
|
| 2068 |
+
'technical_analysis': {
|
| 2069 |
+
'trend': {
|
| 2070 |
+
'ma_trend': 'UNKNOWN',
|
| 2071 |
+
'ma_status': '未知',
|
| 2072 |
+
'ma_values': {'ma5': 0.0, 'ma20': 0.0, 'ma60': 0.0}
|
| 2073 |
+
},
|
| 2074 |
+
'indicators': {
|
| 2075 |
+
'rsi': 50.0,
|
| 2076 |
+
'macd': 0.0,
|
| 2077 |
+
'macd_signal': 0.0,
|
| 2078 |
+
'macd_histogram': 0.0,
|
| 2079 |
+
'volatility': 0.0
|
| 2080 |
+
},
|
| 2081 |
+
'volume': {
|
| 2082 |
+
'current_volume': 0.0,
|
| 2083 |
+
'volume_ratio': 0.0,
|
| 2084 |
+
'volume_status': 'NORMAL'
|
| 2085 |
+
},
|
| 2086 |
+
'support_resistance': {
|
| 2087 |
+
'support_levels': {'short_term': [], 'medium_term': []},
|
| 2088 |
+
'resistance_levels': {'short_term': [], 'medium_term': []}
|
| 2089 |
+
}
|
| 2090 |
+
},
|
| 2091 |
+
'scores': {'total': 0},
|
| 2092 |
+
'recommendation': {'action': '分析出错,无法提供建议'},
|
| 2093 |
+
'ai_analysis': f"分析过程中出错: {str(e)}"
|
| 2094 |
+
}
|
| 2095 |
+
|
| 2096 |
+
return error_report
|
| 2097 |
+
|
| 2098 |
+
# 添加一个辅助方法确保报告结构完整
|
| 2099 |
+
def _validate_and_fix_report(self, report):
|
| 2100 |
+
"""确保分析报告结构完整"""
|
| 2101 |
+
# 检查必要的顶级字段
|
| 2102 |
+
required_sections = ['basic_info', 'price_data', 'technical_analysis', 'scores', 'recommendation',
|
| 2103 |
+
'ai_analysis']
|
| 2104 |
+
for section in required_sections:
|
| 2105 |
+
if section not in report:
|
| 2106 |
+
self.logger.warning(f"报告缺少 {section} 部分,添加空对象")
|
| 2107 |
+
report[section] = {}
|
| 2108 |
+
|
| 2109 |
+
# 检查technical_analysis的结构
|
| 2110 |
+
if 'technical_analysis' in report:
|
| 2111 |
+
tech = report['technical_analysis']
|
| 2112 |
+
if not isinstance(tech, dict):
|
| 2113 |
+
report['technical_analysis'] = {}
|
| 2114 |
+
tech = report['technical_analysis']
|
| 2115 |
+
|
| 2116 |
+
# 检查indicators部分
|
| 2117 |
+
if 'indicators' not in tech or not isinstance(tech['indicators'], dict):
|
| 2118 |
+
tech['indicators'] = {
|
| 2119 |
+
'rsi': 50.0,
|
| 2120 |
+
'macd': 0.0,
|
| 2121 |
+
'macd_signal': 0.0,
|
| 2122 |
+
'macd_histogram': 0.0,
|
| 2123 |
+
'volatility': 0.0
|
| 2124 |
+
}
|
| 2125 |
+
|
| 2126 |
+
# 转换所有指标为原生Python类型
|
| 2127 |
+
for key, value in tech['indicators'].items():
|
| 2128 |
+
try:
|
| 2129 |
+
tech['indicators'][key] = float(value)
|
| 2130 |
+
except (TypeError, ValueError):
|
| 2131 |
+
tech['indicators'][key] = 0.0
|
stock_qa.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
开发者:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
许可证:MIT License
|
| 7 |
+
"""
|
| 8 |
+
# stock_qa.py
|
| 9 |
+
import os
|
| 10 |
+
import openai
|
| 11 |
+
|
| 12 |
+
class StockQA:
|
| 13 |
+
def __init__(self, analyzer, openai_api_key=None, openai_model=None):
|
| 14 |
+
self.analyzer = analyzer
|
| 15 |
+
self.openai_api_key = os.getenv('OPENAI_API_KEY', os.getenv('OPENAI_API_KEY'))
|
| 16 |
+
self.openai_api_url = os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1')
|
| 17 |
+
self.openai_model = os.getenv('OPENAI_API_MODEL', 'gemini-2.0-pro-exp-02-05')
|
| 18 |
+
|
| 19 |
+
def answer_question(self, stock_code, question, market_type='A'):
|
| 20 |
+
"""回答关于股票的问题"""
|
| 21 |
+
try:
|
| 22 |
+
if not self.openai_api_key:
|
| 23 |
+
return {"error": "未配置API密钥,无法使用智能问答功能"}
|
| 24 |
+
|
| 25 |
+
# 获取股票信息
|
| 26 |
+
stock_info = self.analyzer.get_stock_info(stock_code)
|
| 27 |
+
|
| 28 |
+
# 获取技术指标数据
|
| 29 |
+
df = self.analyzer.get_stock_data(stock_code, market_type)
|
| 30 |
+
df = self.analyzer.calculate_indicators(df)
|
| 31 |
+
|
| 32 |
+
# 提取最新数据
|
| 33 |
+
latest = df.iloc[-1]
|
| 34 |
+
|
| 35 |
+
# 计算评分
|
| 36 |
+
score = self.analyzer.calculate_score(df)
|
| 37 |
+
|
| 38 |
+
# 获取支撑压力位
|
| 39 |
+
sr_levels = self.analyzer.identify_support_resistance(df)
|
| 40 |
+
|
| 41 |
+
# 构建上下文
|
| 42 |
+
context = f"""股票信息:
|
| 43 |
+
- 代码: {stock_code}
|
| 44 |
+
- 名称: {stock_info.get('股票名称', '未知')}
|
| 45 |
+
- 行业: {stock_info.get('行业', '未知')}
|
| 46 |
+
|
| 47 |
+
技术指标(最新数据):
|
| 48 |
+
- 价格: {latest['close']}
|
| 49 |
+
- 5日均线: {latest['MA5']}
|
| 50 |
+
- 20日均线: {latest['MA20']}
|
| 51 |
+
- 60日均线: {latest['MA60']}
|
| 52 |
+
- RSI: {latest['RSI']}
|
| 53 |
+
- MACD: {latest['MACD']}
|
| 54 |
+
- MACD信号线: {latest['Signal']}
|
| 55 |
+
- 布林带上轨: {latest['BB_upper']}
|
| 56 |
+
- 布林带中轨: {latest['BB_middle']}
|
| 57 |
+
- 布林带下轨: {latest['BB_lower']}
|
| 58 |
+
- 波动率: {latest['Volatility']}%
|
| 59 |
+
|
| 60 |
+
技术评分: {score}分
|
| 61 |
+
|
| 62 |
+
支撑位:
|
| 63 |
+
- 短期: {', '.join([str(level) for level in sr_levels['support_levels']['short_term']])}
|
| 64 |
+
- 中期: {', '.join([str(level) for level in sr_levels['support_levels']['medium_term']])}
|
| 65 |
+
|
| 66 |
+
压力位:
|
| 67 |
+
- 短期: {', '.join([str(level) for level in sr_levels['resistance_levels']['short_term']])}
|
| 68 |
+
- 中期: {', '.join([str(level) for level in sr_levels['resistance_levels']['medium_term']])}"""
|
| 69 |
+
|
| 70 |
+
# 特定问题类型的补充信息
|
| 71 |
+
if '基本面' in question or '财务' in question or '估值' in question:
|
| 72 |
+
try:
|
| 73 |
+
# 导入基本面分析器
|
| 74 |
+
from fundamental_analyzer import FundamentalAnalyzer
|
| 75 |
+
fundamental = FundamentalAnalyzer()
|
| 76 |
+
|
| 77 |
+
# 获取基本面数据
|
| 78 |
+
indicators = fundamental.get_financial_indicators(stock_code)
|
| 79 |
+
|
| 80 |
+
# 添加到上下文
|
| 81 |
+
context += f"""
|
| 82 |
+
|
| 83 |
+
基本面指标:
|
| 84 |
+
- PE(TTM): {indicators.get('pe_ttm', '未知')}
|
| 85 |
+
- PB: {indicators.get('pb', '未知')}
|
| 86 |
+
- ROE: {indicators.get('roe', '未知')}%
|
| 87 |
+
- 毛利率: {indicators.get('gross_margin', '未知')}%
|
| 88 |
+
- 净利率: {indicators.get('net_profit_margin', '未知')}%"""
|
| 89 |
+
except:
|
| 90 |
+
context += "\n\n注意:未能获取基本面数据"
|
| 91 |
+
|
| 92 |
+
# 调用AI API回答问题
|
| 93 |
+
openai.api_key = self.openai_api_key
|
| 94 |
+
openai.api_base = self.openai_api_url
|
| 95 |
+
|
| 96 |
+
system_content = """你是专业的股票分析师助手,基于'时空共振交易体系'提供分析。
|
| 97 |
+
请基于技术指标和市场数据进行客观分析。
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
response = openai.ChatCompletion.create(
|
| 101 |
+
model=self.openai_model,
|
| 102 |
+
messages=[
|
| 103 |
+
{"role": "system", "content": system_content},
|
| 104 |
+
{"role": "user",
|
| 105 |
+
"content": f"请回答关于股票的问题,并参考以下股票数据:\n\n{context}\n\n问题:{question}"}
|
| 106 |
+
],
|
| 107 |
+
temperature=0.7
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
answer = response.choices[0].message.content
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
"question": question,
|
| 114 |
+
"answer": answer,
|
| 115 |
+
"stock_code": stock_code,
|
| 116 |
+
"stock_name": stock_info.get('股票名称', '未知')
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print(f"智能问答出错: {str(e)}")
|
| 121 |
+
return {
|
| 122 |
+
"question": question,
|
| 123 |
+
"answer": f"抱歉,回答问题时出错: {str(e)}",
|
| 124 |
+
"stock_code": stock_code
|
| 125 |
+
}
|
templates/capital_flow.html
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}资金流向 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-3">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header py-2">
|
| 13 |
+
<h5 class="mb-0">资金流向分析</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body py-2">
|
| 16 |
+
<form id="capital-flow-form" class="row g-2">
|
| 17 |
+
<div class="col-md-3">
|
| 18 |
+
<div class="input-group input-group-sm">
|
| 19 |
+
<span class="input-group-text">数据类型</span>
|
| 20 |
+
<select class="form-select" id="data-type">
|
| 21 |
+
<option value="concept" selected>概念资金流</option>
|
| 22 |
+
<option value="individual" >个股资金流</option>
|
| 23 |
+
</select>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="col-md-3">
|
| 27 |
+
<div class="input-group input-group-sm">
|
| 28 |
+
<span class="input-group-text">周期</span>
|
| 29 |
+
<select class="form-select" id="period-select">
|
| 30 |
+
<option value="10日排行" selected>10日排行</option>
|
| 31 |
+
<option value="5日排行">5日排行</option>
|
| 32 |
+
<option value="3日排行">3日排行</option>
|
| 33 |
+
</select>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
<div class="col-md-4 stock-input" style="display: none;">
|
| 37 |
+
<div class="input-group input-group-sm">
|
| 38 |
+
<span class="input-group-text">股票代码</span>
|
| 39 |
+
<input type="text" class="form-control" id="stock-code" placeholder="例如: 600519">
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
<div class="col-md-2">
|
| 43 |
+
<button type="submit" class="btn btn-primary btn-sm w-100">
|
| 44 |
+
<i class="fas fa-search"></i> 查询
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
</form>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- Loading Panel -->
|
| 54 |
+
<div id="loading-panel" class="text-center py-5" style="display: none;">
|
| 55 |
+
<div class="spinner-border text-primary" role="status">
|
| 56 |
+
<span class="visually-hidden">Loading...</span>
|
| 57 |
+
</div>
|
| 58 |
+
<p class="mt-3 mb-0">正在获取资金流向数据...</p>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Concept Fund Flow Panel -->
|
| 62 |
+
<div id="concept-flow-panel" class="row g-3 mb-3" style="display: none;">
|
| 63 |
+
<div class="col-12">
|
| 64 |
+
<div class="card">
|
| 65 |
+
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
| 66 |
+
<h5 class="mb-0">概念资金流向</h5>
|
| 67 |
+
<span id="concept-period-badge" class="badge bg-primary">10日排行</span>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="card-body">
|
| 70 |
+
<div class="table-responsive">
|
| 71 |
+
<table class="table table-sm table-striped table-hover">
|
| 72 |
+
<thead>
|
| 73 |
+
<tr>
|
| 74 |
+
<th>序号</th>
|
| 75 |
+
<th>概念/行业</th>
|
| 76 |
+
<th>行业指数</th>
|
| 77 |
+
<th>涨跌幅</th>
|
| 78 |
+
<th>流入资金(亿)</th>
|
| 79 |
+
<th>流出资金(亿)</th>
|
| 80 |
+
<th>净额(亿)</th>
|
| 81 |
+
<th>公司家数</th>
|
| 82 |
+
<th>操作</th>
|
| 83 |
+
</tr>
|
| 84 |
+
</thead>
|
| 85 |
+
<tbody id="concept-flow-table">
|
| 86 |
+
<!-- 资金流向数据将在JS中填充 -->
|
| 87 |
+
</tbody>
|
| 88 |
+
</table>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<!-- Concept Stocks Panel -->
|
| 96 |
+
<div id="concept-stocks-panel" class="row g-3 mb-3" style="display: none;">
|
| 97 |
+
<div class="col-12">
|
| 98 |
+
<div class="card">
|
| 99 |
+
<div class="card-header py-2">
|
| 100 |
+
<h5 id="concept-stocks-title" class="mb-0">概念成分股</h5>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="card-body">
|
| 103 |
+
<div class="table-responsive">
|
| 104 |
+
<table class="table table-sm table-striped table-hover">
|
| 105 |
+
<thead>
|
| 106 |
+
<tr>
|
| 107 |
+
<th>代码</th>
|
| 108 |
+
<th>名称</th>
|
| 109 |
+
<th>最新价</th>
|
| 110 |
+
<th>涨跌幅</th>
|
| 111 |
+
<th>主力净流入</th>
|
| 112 |
+
<th>主力净流入占比</th>
|
| 113 |
+
<th>操作</th>
|
| 114 |
+
</tr>
|
| 115 |
+
</thead>
|
| 116 |
+
<tbody id="concept-stocks-table">
|
| 117 |
+
<!-- 概念成分股数据将在JS中填充 -->
|
| 118 |
+
</tbody>
|
| 119 |
+
</table>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- Individual Fund Flow Panel -->
|
| 127 |
+
<div id="individual-flow-panel" class="row g-3 mb-3" style="display: none;">
|
| 128 |
+
<div class="col-12">
|
| 129 |
+
<div class="card">
|
| 130 |
+
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
| 131 |
+
<h5 id="individual-flow-title" class="mb-0">个股资金流向</h5>
|
| 132 |
+
<span id="individual-period-badge" class="badge bg-primary">10日排行</span>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="card-body">
|
| 135 |
+
<div class="row">
|
| 136 |
+
<div class="col-md-6">
|
| 137 |
+
<h6>资金流向概览</h6>
|
| 138 |
+
<table class="table table-sm">
|
| 139 |
+
<tbody id="individual-flow-summary">
|
| 140 |
+
<!-- 个股资金流向概览将在JS中填充 -->
|
| 141 |
+
</tbody>
|
| 142 |
+
</table>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="col-md-6">
|
| 145 |
+
<h6>资金流入占比</h6>
|
| 146 |
+
<div id="fund-flow-pie-chart" style="height: 200px;"></div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="row mt-3">
|
| 150 |
+
<div class="col-12">
|
| 151 |
+
<h6>资金流向历史</h6>
|
| 152 |
+
<div id="fund-flow-history-chart" style="height: 300px;"></div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<!-- Individual Fund Flow Rank Panel -->
|
| 161 |
+
<div id="individual-rank-panel" class="row g-3 mb-3" style="display: none;">
|
| 162 |
+
<div class="col-12">
|
| 163 |
+
<div class="card">
|
| 164 |
+
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
| 165 |
+
<h5 class="mb-0">个股资金流向排名</h5>
|
| 166 |
+
<span id="individual-rank-period-badge" class="badge bg-primary">10日排行</span>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="card-body">
|
| 169 |
+
<div class="table-responsive">
|
| 170 |
+
<table class="table table-sm table-striped table-hover">
|
| 171 |
+
<thead>
|
| 172 |
+
<tr>
|
| 173 |
+
<th>序号</th>
|
| 174 |
+
<th>代码</th>
|
| 175 |
+
<th>名称</th>
|
| 176 |
+
<th>最新价</th>
|
| 177 |
+
<th>涨跌幅</th>
|
| 178 |
+
<th>主力净流入</th>
|
| 179 |
+
<th>主力净流入占比</th>
|
| 180 |
+
<th>超大单净流入</th>
|
| 181 |
+
<th>大单净流入</th>
|
| 182 |
+
<th>中单净流入</th>
|
| 183 |
+
<th>小单净流入</th>
|
| 184 |
+
<th>操作</th>
|
| 185 |
+
</tr>
|
| 186 |
+
</thead>
|
| 187 |
+
<tbody id="individual-rank-table">
|
| 188 |
+
<!-- 个股资金流向排名数据将在JS中填充 -->
|
| 189 |
+
</tbody>
|
| 190 |
+
</table>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
{% endblock %}
|
| 198 |
+
|
| 199 |
+
{% block scripts %}
|
| 200 |
+
<script>
|
| 201 |
+
$(document).ready(function() {
|
| 202 |
+
// 默认加载概念资金流向
|
| 203 |
+
loadConceptFundFlow('90日排行');
|
| 204 |
+
|
| 205 |
+
// 表单提交事件
|
| 206 |
+
$('#capital-flow-form').submit(function(e) {
|
| 207 |
+
e.preventDefault();
|
| 208 |
+
const dataType = $('#data-type').val();
|
| 209 |
+
const period = $('#period-select').val();
|
| 210 |
+
const stockCode = $('#stock-code').val().trim();
|
| 211 |
+
|
| 212 |
+
if (dataType === 'concept') {
|
| 213 |
+
loadConceptFundFlow(period);
|
| 214 |
+
} else if (dataType === 'individual') {
|
| 215 |
+
if (stockCode) {
|
| 216 |
+
loadIndividualFundFlow(stockCode);
|
| 217 |
+
} else {
|
| 218 |
+
loadIndividualFundFlowRank(period);
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
// 数据类型切换事件
|
| 224 |
+
$('#data-type').change(function() {
|
| 225 |
+
const dataType = $(this).val();
|
| 226 |
+
if (dataType === 'individual') {
|
| 227 |
+
$('.stock-input').show();
|
| 228 |
+
} else {
|
| 229 |
+
$('.stock-input').hide();
|
| 230 |
+
}
|
| 231 |
+
});
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
// 加载概念资金流向
|
| 235 |
+
function loadConceptFundFlow(period) {
|
| 236 |
+
$('#loading-panel').show();
|
| 237 |
+
$('#concept-flow-panel, #concept-stocks-panel, #individual-flow-panel, #individual-rank-panel').hide();
|
| 238 |
+
|
| 239 |
+
$.ajax({
|
| 240 |
+
url: `/api/concept_fund_flow?period=${period}`,
|
| 241 |
+
type: 'GET',
|
| 242 |
+
success: function(response) {
|
| 243 |
+
renderConceptFundFlow(response, period);
|
| 244 |
+
$('#loading-panel').hide();
|
| 245 |
+
$('#concept-flow-panel').show();
|
| 246 |
+
},
|
| 247 |
+
error: function(xhr, status, error) {
|
| 248 |
+
$('#loading-panel').hide();
|
| 249 |
+
showError('获取概念资金流向数据失败: ' + error);
|
| 250 |
+
}
|
| 251 |
+
});
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// 加载概念成分股
|
| 255 |
+
function loadConceptStocks(sector) {
|
| 256 |
+
$('#loading-panel').show();
|
| 257 |
+
$('#concept-stocks-panel').hide();
|
| 258 |
+
|
| 259 |
+
$.ajax({
|
| 260 |
+
url: `/api/sector_stocks?sector=${encodeURIComponent(sector)}`,
|
| 261 |
+
type: 'GET',
|
| 262 |
+
success: function(response) {
|
| 263 |
+
renderConceptStocks(response, sector);
|
| 264 |
+
$('#loading-panel').hide();
|
| 265 |
+
$('#concept-stocks-panel').show();
|
| 266 |
+
},
|
| 267 |
+
error: function(xhr, status, error) {
|
| 268 |
+
$('#loading-panel').hide();
|
| 269 |
+
showError('获取概念成分股数据失败: ' + error);
|
| 270 |
+
}
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// 加载个股资金流向
|
| 275 |
+
function loadIndividualFundFlow(stockCode) {
|
| 276 |
+
$('#loading-panel').show();
|
| 277 |
+
$('#concept-flow-panel, #concept-stocks-panel, #individual-flow-panel, #individual-rank-panel').hide();
|
| 278 |
+
|
| 279 |
+
$.ajax({
|
| 280 |
+
url: `/api/individual_fund_flow?stock_code=${stockCode}`,
|
| 281 |
+
type: 'GET',
|
| 282 |
+
success: function(response) {
|
| 283 |
+
renderIndividualFundFlow(response);
|
| 284 |
+
$('#loading-panel').hide();
|
| 285 |
+
$('#individual-flow-panel').show();
|
| 286 |
+
},
|
| 287 |
+
error: function(xhr, status, error) {
|
| 288 |
+
$('#loading-panel').hide();
|
| 289 |
+
showError('获取个股资金流向数据失败: ' + error);
|
| 290 |
+
}
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// 加载个股资金流向排名
|
| 295 |
+
function loadIndividualFundFlowRank(period) {
|
| 296 |
+
$('#loading-panel').show();
|
| 297 |
+
$('#concept-flow-panel, #concept-stocks-panel, #individual-flow-panel, #individual-rank-panel').hide();
|
| 298 |
+
|
| 299 |
+
$.ajax({
|
| 300 |
+
url: `/api/individual_fund_flow_rank?period=${period}`,
|
| 301 |
+
type: 'GET',
|
| 302 |
+
success: function(response) {
|
| 303 |
+
renderIndividualFundFlowRank(response, period);
|
| 304 |
+
$('#loading-panel').hide();
|
| 305 |
+
$('#individual-rank-panel').show();
|
| 306 |
+
},
|
| 307 |
+
error: function(xhr, status, error) {
|
| 308 |
+
$('#loading-panel').hide();
|
| 309 |
+
showError('获取个股资金流向排名数据失败: ' + error);
|
| 310 |
+
}
|
| 311 |
+
});
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// 渲染概念资金流向
|
| 315 |
+
function renderConceptFundFlow(data, period) {
|
| 316 |
+
$('#concept-period-badge').text(period);
|
| 317 |
+
|
| 318 |
+
let html = '';
|
| 319 |
+
if (!data || data.length === 0) {
|
| 320 |
+
html = '<tr><td colspan="9" class="text-center">暂无数据</td></tr>';
|
| 321 |
+
} else {
|
| 322 |
+
data.forEach((item, index) => {
|
| 323 |
+
const changeClass = parseFloat(item.change_percent) >= 0 ? 'trend-up' : 'trend-down';
|
| 324 |
+
const changeIcon = parseFloat(item.change_percent) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 325 |
+
|
| 326 |
+
const netFlowClass = parseFloat(item.net_flow) >= 0 ? 'trend-up' : 'trend-down';
|
| 327 |
+
const netFlowIcon = parseFloat(item.net_flow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 328 |
+
|
| 329 |
+
html += `
|
| 330 |
+
<tr>
|
| 331 |
+
<td>${item.rank}</td>
|
| 332 |
+
<td><a href="javascript:void(0)" onclick="loadConceptStocks('${item.sector}')">${item.sector}</a></td>
|
| 333 |
+
<td>${formatNumber(item.sector_index, 2)}</td>
|
| 334 |
+
<td class="${changeClass}">${changeIcon} ${formatPercent(item.change_percent)}</td>
|
| 335 |
+
<td>${formatNumber(item.inflow, 2)}</td>
|
| 336 |
+
<td>${formatNumber(item.outflow, 2)}</td>
|
| 337 |
+
<td class="${netFlowClass}">${netFlowIcon} ${formatNumber(item.net_flow, 2)}</td>
|
| 338 |
+
<td>${item.company_count}</td>
|
| 339 |
+
<td>
|
| 340 |
+
<button class="btn btn-sm btn-outline-primary" onclick="loadConceptStocks('${item.sector}')">
|
| 341 |
+
<i class="fas fa-search"></i>
|
| 342 |
+
</button>
|
| 343 |
+
</td>
|
| 344 |
+
</tr>
|
| 345 |
+
`;
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
$('#concept-flow-table').html(html);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// 渲染概念成分股
|
| 353 |
+
function renderConceptStocks(data, sector) {
|
| 354 |
+
$('#concept-stocks-title').text(`${sector} 成分股`);
|
| 355 |
+
|
| 356 |
+
let html = '';
|
| 357 |
+
if (!data || data.length === 0) {
|
| 358 |
+
html = '<tr><td colspan="7" class="text-center">暂无数据</td></tr>';
|
| 359 |
+
} else {
|
| 360 |
+
data.forEach((item) => {
|
| 361 |
+
const changeClass = parseFloat(item.change_percent) >= 0 ? 'trend-up' : 'trend-down';
|
| 362 |
+
const changeIcon = parseFloat(item.change_percent) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 363 |
+
|
| 364 |
+
const netFlowClass = parseFloat(item.main_net_inflow) >= 0 ? 'trend-up' : 'trend-down';
|
| 365 |
+
const netFlowIcon = parseFloat(item.main_net_inflow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 366 |
+
|
| 367 |
+
html += `
|
| 368 |
+
<tr>
|
| 369 |
+
<td>${item.code}</td>
|
| 370 |
+
<td>${item.name}</td>
|
| 371 |
+
<td>${formatNumber(item.price, 2)}</td>
|
| 372 |
+
<td class="${changeClass}">${changeIcon} ${formatPercent(item.change_percent)}</td>
|
| 373 |
+
<td class="${netFlowClass}">${netFlowIcon} ${formatMoney(item.main_net_inflow)}</td>
|
| 374 |
+
<td class="${netFlowClass}">${formatPercent(item.main_net_inflow_percent)}</td>
|
| 375 |
+
<td>
|
| 376 |
+
<a href="/stock_detail/${item.code}" class="btn btn-sm btn-outline-primary">
|
| 377 |
+
<i class="fas fa-chart-line"></i>
|
| 378 |
+
</a>
|
| 379 |
+
<button class="btn btn-sm btn-outline-info" onclick="loadIndividualFundFlow('${item.code}')">
|
| 380 |
+
<i class="fas fa-money-bill-wave"></i>
|
| 381 |
+
</button>
|
| 382 |
+
</td>
|
| 383 |
+
</tr>
|
| 384 |
+
`;
|
| 385 |
+
});
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
$('#concept-stocks-table').html(html);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
// 渲染个股资金流向
|
| 392 |
+
function renderIndividualFundFlow(data) {
|
| 393 |
+
if (!data || !data.data || data.data.length === 0) {
|
| 394 |
+
showError('未获取到有效的个股资金流向数据');
|
| 395 |
+
return;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
// Sort data by date (descending - newest first)
|
| 399 |
+
data.data.sort((a, b) => {
|
| 400 |
+
// Parse dates to ensure proper comparison
|
| 401 |
+
let dateA = new Date(a.date);
|
| 402 |
+
let dateB = new Date(b.date);
|
| 403 |
+
return dateB - dateA;
|
| 404 |
+
});
|
| 405 |
+
|
| 406 |
+
// Re-calculate summary for 90 days instead of relying on backend calculation
|
| 407 |
+
recalculateSummary(data, 90);
|
| 408 |
+
|
| 409 |
+
// 设置标题
|
| 410 |
+
$('#individual-flow-title').text(`${data.stock_code} 资金流向`);
|
| 411 |
+
|
| 412 |
+
// 渲染概览
|
| 413 |
+
renderIndividualFlowSummary(data);
|
| 414 |
+
|
| 415 |
+
// 渲染资金流入占比饼图
|
| 416 |
+
renderFundFlowPieChart(data);
|
| 417 |
+
|
| 418 |
+
// 渲染资金流向历史图表
|
| 419 |
+
renderFundFlowHistoryChart(data);
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
function recalculateSummary(data, days) {
|
| 423 |
+
// Get recent data (up to the specified number of days)
|
| 424 |
+
const recent_data = data.data.slice(0, Math.min(days, data.data.length));
|
| 425 |
+
|
| 426 |
+
// Calculate summary statistics
|
| 427 |
+
const total_main_net_inflow = recent_data.reduce((sum, item) => sum + item.main_net_inflow, 0);
|
| 428 |
+
const avg_main_net_inflow_percent = recent_data.reduce((sum, item) => sum + item.main_net_inflow_percent, 0) / recent_data.length;
|
| 429 |
+
const positive_days = recent_data.filter(item => item.main_net_inflow > 0).length;
|
| 430 |
+
const negative_days = recent_data.length - positive_days;
|
| 431 |
+
|
| 432 |
+
// Create or update summary object
|
| 433 |
+
data.summary = {
|
| 434 |
+
recent_days: recent_data.length,
|
| 435 |
+
total_main_net_inflow: total_main_net_inflow,
|
| 436 |
+
avg_main_net_inflow_percent: avg_main_net_inflow_percent,
|
| 437 |
+
positive_days: positive_days,
|
| 438 |
+
negative_days: negative_days
|
| 439 |
+
};
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// 渲染个股资金流向概览
|
| 443 |
+
function renderIndividualFlowSummary(data) {
|
| 444 |
+
if (!data.summary) return;
|
| 445 |
+
|
| 446 |
+
const summary = data.summary;
|
| 447 |
+
// Now using the first item after sorting
|
| 448 |
+
const recent = data.data[0]; // 最近一天的数据
|
| 449 |
+
|
| 450 |
+
let html = `
|
| 451 |
+
<tr>
|
| 452 |
+
<td>最新日期:</td>
|
| 453 |
+
<td>${recent.date}</td>
|
| 454 |
+
<td>最新价:</td>
|
| 455 |
+
<td>${formatNumber(recent.price, 2)}</td>
|
| 456 |
+
</tr>
|
| 457 |
+
<tr>
|
| 458 |
+
<td>涨跌幅:</td>
|
| 459 |
+
<td class="${recent.change_percent >= 0 ? 'trend-up' : 'trend-down'}">
|
| 460 |
+
${recent.change_percent >= 0 ? '↑' : '↓'} ${formatPercent(recent.change_percent)}
|
| 461 |
+
</td>
|
| 462 |
+
<td>分析周期:</td>
|
| 463 |
+
<td>${summary.recent_days}天</td>
|
| 464 |
+
</tr>
|
| 465 |
+
<tr>
|
| 466 |
+
<td>主力净流入:</td>
|
| 467 |
+
<td class="${summary.total_main_net_inflow >= 0 ? 'trend-up' : 'trend-down'}">
|
| 468 |
+
${summary.total_main_net_inflow >= 0 ? '↑' : '↓'} ${formatMoney(summary.total_main_net_inflow)}
|
| 469 |
+
</td>
|
| 470 |
+
<td>净流入占比:</td>
|
| 471 |
+
<td class="${summary.avg_main_net_inflow_percent >= 0 ? 'trend-up' : 'trend-down'}">
|
| 472 |
+
${summary.avg_main_net_inflow_percent >= 0 ? '↑' : '↓'} ${formatPercent(summary.avg_main_net_inflow_percent)}
|
| 473 |
+
</td>
|
| 474 |
+
</tr>
|
| 475 |
+
<tr>
|
| 476 |
+
<td>资金流入天数:</td>
|
| 477 |
+
<td>${summary.positive_days}天</td>
|
| 478 |
+
<td>资金流出天数:</td>
|
| 479 |
+
<td>${summary.negative_days}天</td>
|
| 480 |
+
</tr>
|
| 481 |
+
`;
|
| 482 |
+
|
| 483 |
+
$('#individual-flow-summary').html(html);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
// 渲染资金流入占比饼图
|
| 487 |
+
function renderFundFlowPieChart(data) {
|
| 488 |
+
if (!data.data || data.data.length === 0) return;
|
| 489 |
+
|
| 490 |
+
// Using the first item after sorting
|
| 491 |
+
const recent = data.data[0]; // 最近一天的数据
|
| 492 |
+
|
| 493 |
+
// 计算资金流入总额(绝对值)
|
| 494 |
+
const totalInflow = Math.abs(recent.super_large_net_inflow) +
|
| 495 |
+
Math.abs(recent.large_net_inflow) +
|
| 496 |
+
Math.abs(recent.medium_net_inflow) +
|
| 497 |
+
Math.abs(recent.small_net_inflow);
|
| 498 |
+
|
| 499 |
+
// 计算各类型占比
|
| 500 |
+
const superLargePct = Math.abs(recent.super_large_net_inflow) / totalInflow * 100;
|
| 501 |
+
const largePct = Math.abs(recent.large_net_inflow) / totalInflow * 100;
|
| 502 |
+
const mediumPct = Math.abs(recent.medium_net_inflow) / totalInflow * 100;
|
| 503 |
+
const smallPct = Math.abs(recent.small_net_inflow) / totalInflow * 100;
|
| 504 |
+
|
| 505 |
+
const options = {
|
| 506 |
+
series: [superLargePct, largePct, mediumPct, smallPct],
|
| 507 |
+
chart: {
|
| 508 |
+
type: 'pie',
|
| 509 |
+
height: 200
|
| 510 |
+
},
|
| 511 |
+
labels: ['超大单', '大单', '中单', '小单'],
|
| 512 |
+
colors: ['#0d6efd', '#198754', '#ffc107', '#dc3545'],
|
| 513 |
+
legend: {
|
| 514 |
+
position: 'bottom'
|
| 515 |
+
},
|
| 516 |
+
tooltip: {
|
| 517 |
+
y: {
|
| 518 |
+
formatter: function(value) {
|
| 519 |
+
return value.toFixed(2) + '%';
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
};
|
| 524 |
+
|
| 525 |
+
// 清除旧图表
|
| 526 |
+
$('#fund-flow-pie-chart').empty();
|
| 527 |
+
|
| 528 |
+
const chart = new ApexCharts(document.querySelector("#fund-flow-pie-chart"), options);
|
| 529 |
+
chart.render();
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// 渲染资金流向历史图表
|
| 533 |
+
function renderFundFlowHistoryChart(data) {
|
| 534 |
+
if (!data.data || data.data.length === 0) return;
|
| 535 |
+
|
| 536 |
+
// 最近90天的数据
|
| 537 |
+
// Since we've already sorted the data, just get the first 90 and reverse for chronological display
|
| 538 |
+
const historyData = data.data.slice(0, 90).reverse();
|
| 539 |
+
|
| 540 |
+
const dates = historyData.map(item => item.date);
|
| 541 |
+
const mainNetInflow = historyData.map(item => item.main_net_inflow);
|
| 542 |
+
const superLargeInflow = historyData.map(item => item.super_large_net_inflow);
|
| 543 |
+
const largeInflow = historyData.map(item => item.large_net_inflow);
|
| 544 |
+
const mediumInflow = historyData.map(item => item.medium_net_inflow);
|
| 545 |
+
const smallInflow = historyData.map(item => item.small_net_inflow);
|
| 546 |
+
const priceChanges = historyData.map(item => item.change_percent);
|
| 547 |
+
|
| 548 |
+
const options = {
|
| 549 |
+
series: [
|
| 550 |
+
{
|
| 551 |
+
name: '主力净流入',
|
| 552 |
+
type: 'column',
|
| 553 |
+
data: mainNetInflow
|
| 554 |
+
},
|
| 555 |
+
{
|
| 556 |
+
name: '超大单',
|
| 557 |
+
type: 'line',
|
| 558 |
+
data: superLargeInflow
|
| 559 |
+
},
|
| 560 |
+
{
|
| 561 |
+
name: '大单',
|
| 562 |
+
type: 'line',
|
| 563 |
+
data: largeInflow
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
name: '价格涨跌幅',
|
| 567 |
+
type: 'line',
|
| 568 |
+
data: priceChanges
|
| 569 |
+
}
|
| 570 |
+
],
|
| 571 |
+
chart: {
|
| 572 |
+
height: 300,
|
| 573 |
+
type: 'line',
|
| 574 |
+
toolbar: {
|
| 575 |
+
show: false
|
| 576 |
+
}
|
| 577 |
+
},
|
| 578 |
+
stroke: {
|
| 579 |
+
width: [0, 2, 2, 2],
|
| 580 |
+
curve: 'smooth'
|
| 581 |
+
},
|
| 582 |
+
plotOptions: {
|
| 583 |
+
bar: {
|
| 584 |
+
columnWidth: '50%'
|
| 585 |
+
}
|
| 586 |
+
},
|
| 587 |
+
colors: ['#0d6efd', '#198754', '#ffc107', '#dc3545'],
|
| 588 |
+
dataLabels: {
|
| 589 |
+
enabled: false
|
| 590 |
+
},
|
| 591 |
+
labels: dates,
|
| 592 |
+
xaxis: {
|
| 593 |
+
type: 'category'
|
| 594 |
+
},
|
| 595 |
+
yaxis: [
|
| 596 |
+
{
|
| 597 |
+
title: {
|
| 598 |
+
text: '资金流入(亿)',
|
| 599 |
+
style: {
|
| 600 |
+
fontSize: '12px'
|
| 601 |
+
}
|
| 602 |
+
},
|
| 603 |
+
labels: {
|
| 604 |
+
formatter: function(val) {
|
| 605 |
+
// Convert to 亿 for display (divide by 100 million)
|
| 606 |
+
return (val / 100000000).toFixed(2);
|
| 607 |
+
}
|
| 608 |
+
}
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
opposite: true,
|
| 612 |
+
title: {
|
| 613 |
+
text: '价格涨跌幅(%)',
|
| 614 |
+
style: {
|
| 615 |
+
fontSize: '12px'
|
| 616 |
+
}
|
| 617 |
+
},
|
| 618 |
+
labels: {
|
| 619 |
+
formatter: function(val) {
|
| 620 |
+
return val.toFixed(2);
|
| 621 |
+
}
|
| 622 |
+
},
|
| 623 |
+
min: -10,
|
| 624 |
+
max: 10,
|
| 625 |
+
tickAmount: 5
|
| 626 |
+
}
|
| 627 |
+
],
|
| 628 |
+
tooltip: {
|
| 629 |
+
shared: true,
|
| 630 |
+
intersect: false,
|
| 631 |
+
y: {
|
| 632 |
+
formatter: function(y, { seriesIndex }) {
|
| 633 |
+
if (seriesIndex === 3) {
|
| 634 |
+
return y.toFixed(2) + '%';
|
| 635 |
+
}
|
| 636 |
+
// Display money values in 亿 (hundred million) units
|
| 637 |
+
return (y / 100000000).toFixed(2) + ' 亿';
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
},
|
| 641 |
+
legend: {
|
| 642 |
+
position: 'top'
|
| 643 |
+
}
|
| 644 |
+
};
|
| 645 |
+
|
| 646 |
+
// 清除旧图表
|
| 647 |
+
$('#fund-flow-history-chart').empty();
|
| 648 |
+
|
| 649 |
+
const chart = new ApexCharts(document.querySelector("#fund-flow-history-chart"), options);
|
| 650 |
+
chart.render();
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
// 渲染个股资金流向排名
|
| 654 |
+
function renderIndividualFundFlowRank(data, period) {
|
| 655 |
+
$('#individual-rank-period-badge').text(period);
|
| 656 |
+
|
| 657 |
+
let html = '';
|
| 658 |
+
if (!data || data.length === 0) {
|
| 659 |
+
html = '<tr><td colspan="12" class="text-center">暂无数据</td></tr>';
|
| 660 |
+
} else {
|
| 661 |
+
data.forEach((item) => {
|
| 662 |
+
const changeClass = parseFloat(item.change_percent) >= 0 ? 'trend-up' : 'trend-down';
|
| 663 |
+
const changeIcon = parseFloat(item.change_percent) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 664 |
+
|
| 665 |
+
const mainNetClass = parseFloat(item.main_net_inflow) >= 0 ? 'trend-up' : 'trend-down';
|
| 666 |
+
const mainNetIcon = parseFloat(item.main_net_inflow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 667 |
+
|
| 668 |
+
html += `
|
| 669 |
+
<tr>
|
| 670 |
+
<td>${item.rank}</td>
|
| 671 |
+
<td>${item.code}</td>
|
| 672 |
+
<td>${item.name}</td>
|
| 673 |
+
<td>${formatNumber(item.price, 2)}</td>
|
| 674 |
+
<td class="${changeClass}">${changeIcon} ${formatPercent(item.change_percent)}</td>
|
| 675 |
+
<td class="${mainNetClass}">${mainNetIcon} ${formatMoney(item.main_net_inflow)}</td>
|
| 676 |
+
<td class="${mainNetClass}">${formatPercent(item.main_net_inflow_percent)}</td>
|
| 677 |
+
<td>${formatMoney(item.super_large_net_inflow)}</td>
|
| 678 |
+
<td>${formatMoney(item.large_net_inflow)}</td>
|
| 679 |
+
<td>${formatMoney(item.medium_net_inflow)}</td>
|
| 680 |
+
<td>${formatMoney(item.small_net_inflow)}</td>
|
| 681 |
+
<td>
|
| 682 |
+
<a href="/stock_detail/${item.code}" class="btn btn-sm btn-outline-primary">
|
| 683 |
+
<i class="fas fa-chart-line"></i>
|
| 684 |
+
</a>
|
| 685 |
+
<button class="btn btn-sm btn-outline-info" onclick="loadIndividualFundFlow('${item.code}')">
|
| 686 |
+
<i class="fas fa-money-bill-wave"></i>
|
| 687 |
+
</button>
|
| 688 |
+
</td>
|
| 689 |
+
</tr>
|
| 690 |
+
`;
|
| 691 |
+
});
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
$('#individual-rank-table').html(html);
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
// 格式化资金数字(支持大数字缩写)
|
| 698 |
+
function formatCompactNumber(num) {
|
| 699 |
+
if (Math.abs(num) >= 1.0e9) {
|
| 700 |
+
return (num / 1.0e9).toFixed(2) + "B";
|
| 701 |
+
} else if (Math.abs(num) >= 1.0e6) {
|
| 702 |
+
return (num / 1.0e6).toFixed(2) + "M";
|
| 703 |
+
} else if (Math.abs(num) >= 1.0e3) {
|
| 704 |
+
return (num / 1.0e3).toFixed(2) + "K";
|
| 705 |
+
} else {
|
| 706 |
+
return num.toFixed(2);
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
// 格式化资金
|
| 711 |
+
function formatMoney(value) {
|
| 712 |
+
if (value === null || value === undefined) {
|
| 713 |
+
return '--';
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
value = parseFloat(value);
|
| 717 |
+
if (isNaN(value)) {
|
| 718 |
+
return '--';
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
if (Math.abs(value) >= 1e8) {
|
| 722 |
+
return (value / 1e8).toFixed(2) + ' 亿';
|
| 723 |
+
} else if (Math.abs(value) >= 1e4) {
|
| 724 |
+
return (value / 1e4).toFixed(2) + ' 万';
|
| 725 |
+
} else {
|
| 726 |
+
return value.toFixed(2) + ' 元';
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
// 格式化百分比
|
| 731 |
+
function formatPercent(value) {
|
| 732 |
+
if (value === null || value === undefined) {
|
| 733 |
+
return '--';
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
value = parseFloat(value);
|
| 737 |
+
if (isNaN(value)) {
|
| 738 |
+
return '--';
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
return value.toFixed(2) + '%';
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 745 |
+
const dataType = document.getElementById('data-type');
|
| 746 |
+
const periodSelect = document.getElementById('period-select');
|
| 747 |
+
const stockInput = document.querySelector('.stock-input');
|
| 748 |
+
|
| 749 |
+
// 初始加载时检查默认值
|
| 750 |
+
toggleOptions();
|
| 751 |
+
|
| 752 |
+
dataType.addEventListener('change', toggleOptions);
|
| 753 |
+
|
| 754 |
+
function toggleOptions() {
|
| 755 |
+
if (dataType.value === 'individual') {
|
| 756 |
+
// 个股资金流选项
|
| 757 |
+
periodSelect.innerHTML = `
|
| 758 |
+
<option value="3日">3日</option>
|
| 759 |
+
<option value="5日">5日</option>
|
| 760 |
+
<option value="10日">10日</option>
|
| 761 |
+
`;
|
| 762 |
+
stockInput.style.display = 'block';
|
| 763 |
+
} else {
|
| 764 |
+
// 概念资金流选项
|
| 765 |
+
periodSelect.innerHTML = `
|
| 766 |
+
<option value="10日排行" selected>10日排行</option>
|
| 767 |
+
<option value="5日排行">5日排行</option>
|
| 768 |
+
<option value="3日排行">3日排行</option>
|
| 769 |
+
`;
|
| 770 |
+
stockInput.style.display = 'none';
|
| 771 |
+
}
|
| 772 |
+
}
|
| 773 |
+
});
|
| 774 |
+
</script>
|
| 775 |
+
{% endblock %}
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}智能仪表盘 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
<div class="row g-3 mb-3">
|
| 9 |
+
<div class="col-12">
|
| 10 |
+
<div class="card">
|
| 11 |
+
<div class="card-header py-1"> <!-- 减少padding-top和padding-bottom -->
|
| 12 |
+
<h5 class="mb-0">智能股票分析</h5>
|
| 13 |
+
</div>
|
| 14 |
+
<div class="card-body py-2"> <!-- 减少padding-top和padding-bottom -->
|
| 15 |
+
<form id="analysis-form" class="row g-2"> <!-- 减少间距g-3到g-2 -->
|
| 16 |
+
<div class="col-md-4">
|
| 17 |
+
<div class="input-group input-group-sm"> <!-- 添加input-group-sm使输入框更小 -->
|
| 18 |
+
<span class="input-group-text">股票代码</span>
|
| 19 |
+
<input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="col-md-3">
|
| 23 |
+
<div class="input-group input-group-sm"> <!-- 添加input-group-sm使下拉框更小 -->
|
| 24 |
+
<span class="input-group-text">市场</span>
|
| 25 |
+
<select class="form-select" id="market-type">
|
| 26 |
+
<option value="A" selected>A股</option>
|
| 27 |
+
<option value="HK">港股</option>
|
| 28 |
+
<option value="US">美股</option>
|
| 29 |
+
</select>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="col-md-3">
|
| 33 |
+
<div class="input-group input-group-sm"> <!-- 添加input-group-sm使下拉框更小 -->
|
| 34 |
+
<span class="input-group-text">周期</span>
|
| 35 |
+
<select class="form-select" id="analysis-period">
|
| 36 |
+
<option value="1m">1个月</option>
|
| 37 |
+
<option value="3m">3个月</option>
|
| 38 |
+
<option value="6m">6个月</option>
|
| 39 |
+
<option value="1y" selected>1年</option>
|
| 40 |
+
</select>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="col-md-2">
|
| 44 |
+
<button type="submit" class="btn btn-primary btn-sm w-100"> <!-- 使用btn-sm减小按钮尺寸 -->
|
| 45 |
+
<i class="fas fa-chart-line"></i> 分析
|
| 46 |
+
</button>
|
| 47 |
+
</div>
|
| 48 |
+
</form>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div id="analysis-result" style="display: none;">
|
| 55 |
+
<div class="row g-3 mb-3">
|
| 56 |
+
<div class="col-md-6">
|
| 57 |
+
<div class="card h-100">
|
| 58 |
+
<div class="card-header py-2">
|
| 59 |
+
<h5 class="mb-0">股票概要</h5>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="card-body">
|
| 62 |
+
<div class="row mb-3">
|
| 63 |
+
<div class="col-md-7">
|
| 64 |
+
<h2 id="stock-name" class="mb-0 fs-4"></h2>
|
| 65 |
+
<p id="stock-info" class="text-muted mb-0 small"></p>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="col-md-5 text-end">
|
| 68 |
+
<h3 id="stock-price" class="mb-0 fs-4"></h3>
|
| 69 |
+
<p id="price-change" class="mb-0"></p>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="row">
|
| 73 |
+
<div class="col-md-6">
|
| 74 |
+
<div class="mb-2">
|
| 75 |
+
<span class="text-muted small">综合评分:</span>
|
| 76 |
+
<div class="mt-1">
|
| 77 |
+
<span id="total-score" class="badge rounded-pill score-pill"></span>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="mb-2">
|
| 81 |
+
<span class="text-muted small">投资建议:</span>
|
| 82 |
+
<p id="recommendation" class="mb-0 text-strong"></p>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
<div class="col-md-6">
|
| 86 |
+
<div class="mb-2">
|
| 87 |
+
<span class="text-muted small">技术面指标:</span>
|
| 88 |
+
<ul class="list-unstyled mt-1 mb-0 small">
|
| 89 |
+
<li><span class="text-muted">RSI:</span> <span id="rsi-value"></span></li>
|
| 90 |
+
<li><span class="text-muted">MA趋势:</span> <span id="ma-trend"></span></li>
|
| 91 |
+
<li><span class="text-muted">MACD信号:</span> <span id="macd-signal"></span></li>
|
| 92 |
+
<li><span class="text-muted">波动率:</span> <span id="volatility"></span></li>
|
| 93 |
+
</ul>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="col-md-6">
|
| 101 |
+
<div class="card h-100">
|
| 102 |
+
<div class="card-header py-2">
|
| 103 |
+
<h5 class="mb-0">多维度评分</h5>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="card-body">
|
| 106 |
+
<div id="radar-chart" style="height: 200px;"></div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div class="row g-3 mb-3">
|
| 113 |
+
<div class="col-12">
|
| 114 |
+
<div class="card">
|
| 115 |
+
<div class="card-header py-2">
|
| 116 |
+
<h5 class="mb-0">价格与技术指标</h5>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="card-body p-0">
|
| 119 |
+
<div id="price-chart" style="height: 400px;"></div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="row g-3 mb-3">
|
| 126 |
+
<div class="col-md-4">
|
| 127 |
+
<div class="card h-100">
|
| 128 |
+
<div class="card-header py-2">
|
| 129 |
+
<h5 class="mb-0">支撑与压力位</h5>
|
| 130 |
+
</div>
|
| 131 |
+
<div class="card-body">
|
| 132 |
+
<table class="table table-sm">
|
| 133 |
+
<thead>
|
| 134 |
+
<tr>
|
| 135 |
+
<th>类型</th>
|
| 136 |
+
<th>价格</th>
|
| 137 |
+
<th>距离</th>
|
| 138 |
+
</tr>
|
| 139 |
+
</thead>
|
| 140 |
+
<tbody id="support-resistance-table">
|
| 141 |
+
<!-- 支撑压力位数据将在JS中动态填充 -->
|
| 142 |
+
</tbody>
|
| 143 |
+
</table>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="col-md-8">
|
| 148 |
+
<div class="card h-100">
|
| 149 |
+
<div class="card-header py-2">
|
| 150 |
+
<h5 class="mb-0">AI分析建议</h5>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="card-body">
|
| 153 |
+
<div id="ai-analysis" class="analysis-section">
|
| 154 |
+
<!-- AI分析结果将在JS中动态填充 -->
|
| 155 |
+
<div class="loading">
|
| 156 |
+
<div class="spinner-border text-primary" role="status">
|
| 157 |
+
<span class="visually-hidden">Loading...</span>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
{% endblock %}
|
| 168 |
+
|
| 169 |
+
{% block scripts %}
|
| 170 |
+
<script>
|
| 171 |
+
let stockData = [];
|
| 172 |
+
let analysisResult = null;
|
| 173 |
+
|
| 174 |
+
// 提交表单进行分析
|
| 175 |
+
$('#analysis-form').submit(function(e) {
|
| 176 |
+
e.preventDefault();
|
| 177 |
+
const stockCode = $('#stock-code').val().trim();
|
| 178 |
+
const marketType = $('#market-type').val();
|
| 179 |
+
const period = $('#analysis-period').val();
|
| 180 |
+
|
| 181 |
+
if (!stockCode) {
|
| 182 |
+
showError('请输入股票代码!');
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// 重定向到股票详情页
|
| 187 |
+
window.location.href = `/stock_detail/${stockCode}?market_type=${marketType}&period=${period}`;
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
// Format AI analysis text
|
| 191 |
+
function formatAIAnalysis(text) {
|
| 192 |
+
if (!text) return '';
|
| 193 |
+
|
| 194 |
+
// First, make the text safe for HTML
|
| 195 |
+
const safeText = text
|
| 196 |
+
.replace(/&/g, '&')
|
| 197 |
+
.replace(/</g, '<')
|
| 198 |
+
.replace(/>/g, '>');
|
| 199 |
+
|
| 200 |
+
// Replace basic Markdown elements
|
| 201 |
+
let formatted = safeText
|
| 202 |
+
// Bold text with ** or __
|
| 203 |
+
.replace(/\*\*(.*?)\*\*/g, '<strong class="keyword">$1</strong>')
|
| 204 |
+
.replace(/__(.*?)__/g, '<strong>$1</strong>')
|
| 205 |
+
|
| 206 |
+
// Italic text with * or _
|
| 207 |
+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
| 208 |
+
.replace(/_(.*?)_/g, '<em>$1</em>')
|
| 209 |
+
|
| 210 |
+
// Headers
|
| 211 |
+
.replace(/^# (.*?)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
|
| 212 |
+
.replace(/^## (.*?)$/gm, '<h5 class="mt-2 mb-2">$1</h5>')
|
| 213 |
+
|
| 214 |
+
// Apply special styling to financial terms
|
| 215 |
+
.replace(/支撑位/g, '<span class="keyword">支撑位</span>')
|
| 216 |
+
.replace(/压力位/g, '<span class="keyword">压力位</span>')
|
| 217 |
+
.replace(/趋势/g, '<span class="keyword">趋势</span>')
|
| 218 |
+
.replace(/均线/g, '<span class="keyword">均线</span>')
|
| 219 |
+
.replace(/MACD/g, '<span class="term">MACD</span>')
|
| 220 |
+
.replace(/RSI/g, '<span class="term">RSI</span>')
|
| 221 |
+
.replace(/KDJ/g, '<span class="term">KDJ</span>')
|
| 222 |
+
|
| 223 |
+
// Highlight price patterns and movements
|
| 224 |
+
.replace(/([上涨升])/g, '<span class="trend-up">$1</span>')
|
| 225 |
+
.replace(/([下跌降])/g, '<span class="trend-down">$1</span>')
|
| 226 |
+
.replace(/(买入|做多|多头|突破)/g, '<span class="trend-up">$1</span>')
|
| 227 |
+
.replace(/(卖出|做空|空头|跌破)/g, '<span class="trend-down">$1</span>')
|
| 228 |
+
|
| 229 |
+
// Highlight price values (matches patterns like 31.25, 120.50)
|
| 230 |
+
.replace(/(\d+\.\d{2})/g, '<span class="price">$1</span>')
|
| 231 |
+
|
| 232 |
+
// Convert line breaks to paragraph tags
|
| 233 |
+
.replace(/\n\n+/g, '</p><p class="analysis-para">')
|
| 234 |
+
.replace(/\n/g, '<br>');
|
| 235 |
+
|
| 236 |
+
// Wrap in paragraph tags for consistent styling
|
| 237 |
+
return '<p class="analysis-para">' + formatted + '</p>';
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// 获取股票数据
|
| 241 |
+
function fetchStockData(stockCode, marketType, period) {
|
| 242 |
+
showLoading();
|
| 243 |
+
|
| 244 |
+
$.ajax({
|
| 245 |
+
url: `/api/stock_data?stock_code=${stockCode}&market_type=${marketType}&period=${period}`,
|
| 246 |
+
type: 'GET',
|
| 247 |
+
dataType: 'json',
|
| 248 |
+
success: function(response) {
|
| 249 |
+
|
| 250 |
+
// 检查response是否有data属性
|
| 251 |
+
if (!response.data) {
|
| 252 |
+
hideLoading();
|
| 253 |
+
showError('响应格式不正确: 缺少data字段');
|
| 254 |
+
return;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
if (response.data.length === 0) {
|
| 258 |
+
hideLoading();
|
| 259 |
+
showError('未找到股票数据');
|
| 260 |
+
return;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
stockData = response.data;
|
| 264 |
+
|
| 265 |
+
// 获取增强分析数据
|
| 266 |
+
fetchEnhancedAnalysis(stockCode, marketType);
|
| 267 |
+
},
|
| 268 |
+
error: function(xhr, status, error) {
|
| 269 |
+
hideLoading();
|
| 270 |
+
|
| 271 |
+
let errorMsg = '获取股票数据失败';
|
| 272 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 273 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 274 |
+
} else if (error) {
|
| 275 |
+
errorMsg += ': ' + error;
|
| 276 |
+
}
|
| 277 |
+
showError(errorMsg);
|
| 278 |
+
}
|
| 279 |
+
});
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// 获取增强分析数据
|
| 283 |
+
function fetchEnhancedAnalysis(stockCode, marketType) {
|
| 284 |
+
|
| 285 |
+
$.ajax({
|
| 286 |
+
url: '/api/enhanced_analysis?_=' + new Date().getTime(),
|
| 287 |
+
type: 'POST',
|
| 288 |
+
contentType: 'application/json',
|
| 289 |
+
data: JSON.stringify({
|
| 290 |
+
stock_code: stockCode,
|
| 291 |
+
market_type: marketType
|
| 292 |
+
}),
|
| 293 |
+
success: function(response) {
|
| 294 |
+
|
| 295 |
+
if (!response.result) {
|
| 296 |
+
hideLoading();
|
| 297 |
+
showError('增强分析响应格式不正确');
|
| 298 |
+
return;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
analysisResult = response.result;
|
| 302 |
+
renderAnalysisResult();
|
| 303 |
+
hideLoading();
|
| 304 |
+
$('#analysis-result').show();
|
| 305 |
+
},
|
| 306 |
+
error: function(xhr, status, error) {
|
| 307 |
+
hideLoading();
|
| 308 |
+
|
| 309 |
+
let errorMsg = '获取分析数据失败';
|
| 310 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 311 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 312 |
+
} else if (error) {
|
| 313 |
+
errorMsg += ': ' + error;
|
| 314 |
+
}
|
| 315 |
+
showError(errorMsg);
|
| 316 |
+
}
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// 渲染分析结果
|
| 321 |
+
function renderAnalysisResult() {
|
| 322 |
+
if (!analysisResult) return;
|
| 323 |
+
|
| 324 |
+
// 渲染股票基本信息
|
| 325 |
+
$('#stock-name').text(analysisResult.basic_info.stock_name + ' (' + analysisResult.basic_info.stock_code + ')');
|
| 326 |
+
$('#stock-info').text(analysisResult.basic_info.industry + ' | ' + analysisResult.basic_info.analysis_date);
|
| 327 |
+
|
| 328 |
+
// 渲染价格信息
|
| 329 |
+
$('#stock-price').text('¥' + formatNumber(analysisResult.price_data.current_price, 2));
|
| 330 |
+
const priceChangeClass = analysisResult.price_data.price_change >= 0 ? 'trend-up' : 'trend-down';
|
| 331 |
+
const priceChangeIcon = analysisResult.price_data.price_change >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 332 |
+
$('#price-change').html(`<span class="${priceChangeClass}">${priceChangeIcon} ${formatNumber(analysisResult.price_data.price_change_value, 2)} (${formatPercent(analysisResult.price_data.price_change, 2)})</span>`);
|
| 333 |
+
|
| 334 |
+
// 渲染评分和建议
|
| 335 |
+
const scoreClass = getScoreColorClass(analysisResult.scores.total_score);
|
| 336 |
+
$('#total-score').text(analysisResult.scores.total_score).addClass(scoreClass);
|
| 337 |
+
$('#recommendation').text(analysisResult.recommendation.action);
|
| 338 |
+
|
| 339 |
+
// 渲染技术指标
|
| 340 |
+
$('#rsi-value').text(formatNumber(analysisResult.technical_analysis.indicators.rsi, 2));
|
| 341 |
+
|
| 342 |
+
const maTrendClass = getTrendColorClass(analysisResult.technical_analysis.trend.ma_trend);
|
| 343 |
+
const maTrendIcon = getTrendIcon(analysisResult.technical_analysis.trend.ma_trend);
|
| 344 |
+
$('#ma-trend').html(`<span class="${maTrendClass}">${maTrendIcon} ${analysisResult.technical_analysis.trend.ma_status}</span>`);
|
| 345 |
+
|
| 346 |
+
const macdSignal = analysisResult.technical_analysis.indicators.macd > analysisResult.technical_analysis.indicators.macd_signal ? 'BUY' : 'SELL';
|
| 347 |
+
const macdClass = macdSignal === 'BUY' ? 'trend-up' : 'trend-down';
|
| 348 |
+
const macdIcon = macdSignal === 'BUY' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| 349 |
+
$('#macd-signal').html(`<span class="${macdClass}">${macdIcon} ${macdSignal}</span>`);
|
| 350 |
+
|
| 351 |
+
$('#volatility').text(formatPercent(analysisResult.technical_analysis.indicators.volatility, 2));
|
| 352 |
+
|
| 353 |
+
// 渲染支撑压力位
|
| 354 |
+
let supportResistanceHtml = '';
|
| 355 |
+
|
| 356 |
+
// 渲染压力位
|
| 357 |
+
if (analysisResult.technical_analysis.support_resistance.resistance &&
|
| 358 |
+
analysisResult.technical_analysis.support_resistance.resistance.short_term &&
|
| 359 |
+
analysisResult.technical_analysis.support_resistance.resistance.short_term.length > 0) {
|
| 360 |
+
const resistance = analysisResult.technical_analysis.support_resistance.resistance.short_term[0];
|
| 361 |
+
const distance = ((resistance - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
|
| 362 |
+
supportResistanceHtml += `
|
| 363 |
+
<tr>
|
| 364 |
+
<td><span class="badge bg-danger">短期压力</span></td>
|
| 365 |
+
<td>${formatNumber(resistance, 2)}</td>
|
| 366 |
+
<td>+${distance}%</td>
|
| 367 |
+
</tr>
|
| 368 |
+
`;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
if (analysisResult.technical_analysis.support_resistance.resistance &&
|
| 372 |
+
analysisResult.technical_analysis.support_resistance.resistance.medium_term &&
|
| 373 |
+
analysisResult.technical_analysis.support_resistance.resistance.medium_term.length > 0) {
|
| 374 |
+
const resistance = analysisResult.technical_analysis.support_resistance.resistance.medium_term[0];
|
| 375 |
+
const distance = ((resistance - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
|
| 376 |
+
supportResistanceHtml += `
|
| 377 |
+
<tr>
|
| 378 |
+
<td><span class="badge bg-warning text-dark">中期压力</span></td>
|
| 379 |
+
<td>${formatNumber(resistance, 2)}</td>
|
| 380 |
+
<td>+${distance}%</td>
|
| 381 |
+
</tr>
|
| 382 |
+
`;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
// 渲染支撑位
|
| 386 |
+
if (analysisResult.technical_analysis.support_resistance.support &&
|
| 387 |
+
analysisResult.technical_analysis.support_resistance.support.short_term &&
|
| 388 |
+
analysisResult.technical_analysis.support_resistance.support.short_term.length > 0) {
|
| 389 |
+
const support = analysisResult.technical_analysis.support_resistance.support.short_term[0];
|
| 390 |
+
const distance = ((support - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
|
| 391 |
+
supportResistanceHtml += `
|
| 392 |
+
<tr>
|
| 393 |
+
<td><span class="badge bg-success">短期支撑</span></td>
|
| 394 |
+
<td>${formatNumber(support, 2)}</td>
|
| 395 |
+
<td>${distance}%</td>
|
| 396 |
+
</tr>
|
| 397 |
+
`;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
if (analysisResult.technical_analysis.support_resistance.support &&
|
| 401 |
+
analysisResult.technical_analysis.support_resistance.support.medium_term &&
|
| 402 |
+
analysisResult.technical_analysis.support_resistance.support.medium_term.length > 0) {
|
| 403 |
+
const support = analysisResult.technical_analysis.support_resistance.support.medium_term[0];
|
| 404 |
+
const distance = ((support - analysisResult.price_data.current_price) / analysisResult.price_data.current_price * 100).toFixed(2);
|
| 405 |
+
supportResistanceHtml += `
|
| 406 |
+
<tr>
|
| 407 |
+
<td><span class="badge bg-info">中期支撑</span></td>
|
| 408 |
+
<td>${formatNumber(support, 2)}</td>
|
| 409 |
+
<td>${distance}%</td>
|
| 410 |
+
</tr>
|
| 411 |
+
`;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
$('#support-resistance-table').html(supportResistanceHtml);
|
| 415 |
+
|
| 416 |
+
// 渲染AI分析
|
| 417 |
+
$('#ai-analysis').html(formatAIAnalysis(analysisResult.ai_analysis));
|
| 418 |
+
|
| 419 |
+
// 绘制雷达图
|
| 420 |
+
renderRadarChart();
|
| 421 |
+
|
| 422 |
+
// 绘制价格图表
|
| 423 |
+
renderPriceChart();
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
// 绘制雷达图
|
| 427 |
+
function renderRadarChart() {
|
| 428 |
+
if (!analysisResult) return;
|
| 429 |
+
|
| 430 |
+
const options = {
|
| 431 |
+
series: [{
|
| 432 |
+
name: '评分',
|
| 433 |
+
data: [
|
| 434 |
+
analysisResult.scores.trend_score || 0,
|
| 435 |
+
analysisResult.scores.indicators_score || 0,
|
| 436 |
+
analysisResult.scores.support_resistance_score || 0,
|
| 437 |
+
analysisResult.scores.volatility_volume_score || 0
|
| 438 |
+
]
|
| 439 |
+
}],
|
| 440 |
+
chart: {
|
| 441 |
+
height: 200,
|
| 442 |
+
type: 'radar',
|
| 443 |
+
toolbar: {
|
| 444 |
+
show: false
|
| 445 |
+
}
|
| 446 |
+
},
|
| 447 |
+
title: {
|
| 448 |
+
text: '多维度技术分析评分',
|
| 449 |
+
style: {
|
| 450 |
+
fontSize: '14px'
|
| 451 |
+
}
|
| 452 |
+
},
|
| 453 |
+
xaxis: {
|
| 454 |
+
categories: ['趋势分析', '技术指标', '支撑压力位', '波动与成交量']
|
| 455 |
+
},
|
| 456 |
+
yaxis: {
|
| 457 |
+
max: 10,
|
| 458 |
+
min: 0
|
| 459 |
+
},
|
| 460 |
+
fill: {
|
| 461 |
+
opacity: 0.5,
|
| 462 |
+
colors: ['#4e73df']
|
| 463 |
+
},
|
| 464 |
+
markers: {
|
| 465 |
+
size: 4
|
| 466 |
+
}
|
| 467 |
+
};
|
| 468 |
+
|
| 469 |
+
// 清除旧图表
|
| 470 |
+
$('#radar-chart').empty();
|
| 471 |
+
|
| 472 |
+
const chart = new ApexCharts(document.querySelector("#radar-chart"), options);
|
| 473 |
+
chart.render();
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
// 绘制价格图表
|
| 477 |
+
function renderPriceChart() {
|
| 478 |
+
if (!stockData || stockData.length === 0) return;
|
| 479 |
+
|
| 480 |
+
// 准备价格数据
|
| 481 |
+
const seriesData = [];
|
| 482 |
+
|
| 483 |
+
// 添加蜡烛图数据
|
| 484 |
+
const candleData = stockData.map(item => ({
|
| 485 |
+
x: new Date(item.date),
|
| 486 |
+
y: [item.open, item.high, item.low, item.close]
|
| 487 |
+
}));
|
| 488 |
+
seriesData.push({
|
| 489 |
+
name: '价格',
|
| 490 |
+
type: 'candlestick',
|
| 491 |
+
data: candleData
|
| 492 |
+
});
|
| 493 |
+
|
| 494 |
+
// 添加均线数据
|
| 495 |
+
const ma5Data = stockData.map(item => ({
|
| 496 |
+
x: new Date(item.date),
|
| 497 |
+
y: item.MA5
|
| 498 |
+
}));
|
| 499 |
+
seriesData.push({
|
| 500 |
+
name: 'MA5',
|
| 501 |
+
type: 'line',
|
| 502 |
+
data: ma5Data
|
| 503 |
+
});
|
| 504 |
+
|
| 505 |
+
const ma20Data = stockData.map(item => ({
|
| 506 |
+
x: new Date(item.date),
|
| 507 |
+
y: item.MA20
|
| 508 |
+
}));
|
| 509 |
+
seriesData.push({
|
| 510 |
+
name: 'MA20',
|
| 511 |
+
type: 'line',
|
| 512 |
+
data: ma20Data
|
| 513 |
+
});
|
| 514 |
+
|
| 515 |
+
const ma60Data = stockData.map(item => ({
|
| 516 |
+
x: new Date(item.date),
|
| 517 |
+
y: item.MA60
|
| 518 |
+
}));
|
| 519 |
+
seriesData.push({
|
| 520 |
+
name: 'MA60',
|
| 521 |
+
type: 'line',
|
| 522 |
+
data: ma60Data
|
| 523 |
+
});
|
| 524 |
+
|
| 525 |
+
// 创建图表
|
| 526 |
+
const options = {
|
| 527 |
+
series: seriesData,
|
| 528 |
+
chart: {
|
| 529 |
+
height: 400,
|
| 530 |
+
type: 'candlestick',
|
| 531 |
+
toolbar: {
|
| 532 |
+
show: true,
|
| 533 |
+
tools: {
|
| 534 |
+
download: true,
|
| 535 |
+
selection: true,
|
| 536 |
+
zoom: true,
|
| 537 |
+
zoomin: true,
|
| 538 |
+
zoomout: true,
|
| 539 |
+
pan: true,
|
| 540 |
+
reset: true
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
},
|
| 544 |
+
title: {
|
| 545 |
+
text: `${analysisResult.basic_info.stock_name} (${analysisResult.basic_info.stock_code}) 价格走势`,
|
| 546 |
+
align: 'left',
|
| 547 |
+
style: {
|
| 548 |
+
fontSize: '14px'
|
| 549 |
+
}
|
| 550 |
+
},
|
| 551 |
+
xaxis: {
|
| 552 |
+
type: 'datetime'
|
| 553 |
+
},
|
| 554 |
+
yaxis: {
|
| 555 |
+
tooltip: {
|
| 556 |
+
enabled: true
|
| 557 |
+
},
|
| 558 |
+
labels: {
|
| 559 |
+
formatter: function(value) {
|
| 560 |
+
return formatNumber(value, 2); // 统一使用2位小数
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
},
|
| 564 |
+
tooltip: {
|
| 565 |
+
shared: true,
|
| 566 |
+
custom: [
|
| 567 |
+
function({ seriesIndex, dataPointIndex, w }) {
|
| 568 |
+
if (seriesIndex === 0) {
|
| 569 |
+
const o = w.globals.seriesCandleO[seriesIndex][dataPointIndex];
|
| 570 |
+
const h = w.globals.seriesCandleH[seriesIndex][dataPointIndex];
|
| 571 |
+
const l = w.globals.seriesCandleL[seriesIndex][dataPointIndex];
|
| 572 |
+
const c = w.globals.seriesCandleC[seriesIndex][dataPointIndex];
|
| 573 |
+
|
| 574 |
+
return `
|
| 575 |
+
<div class="apexcharts-tooltip-candlestick">
|
| 576 |
+
<div>开盘: <span>${formatNumber(o, 2)}</span></div>
|
| 577 |
+
<div>最高: <span>${formatNumber(h, 2)}</span></div>
|
| 578 |
+
<div>最低: <span>${formatNumber(l, 2)}</span></div>
|
| 579 |
+
<div>收盘: <span>${formatNumber(c, 2)}</span></div>
|
| 580 |
+
</div>
|
| 581 |
+
`;
|
| 582 |
+
}
|
| 583 |
+
return '';
|
| 584 |
+
}
|
| 585 |
+
]
|
| 586 |
+
},
|
| 587 |
+
plotOptions: {
|
| 588 |
+
candlestick: {
|
| 589 |
+
colors: {
|
| 590 |
+
upward: '#3C90EB',
|
| 591 |
+
downward: '#DF7D46'
|
| 592 |
+
}
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
};
|
| 596 |
+
|
| 597 |
+
// 清除旧图表
|
| 598 |
+
$('#price-chart').empty();
|
| 599 |
+
|
| 600 |
+
const chart = new ApexCharts(document.querySelector("#price-chart"), options);
|
| 601 |
+
chart.render();
|
| 602 |
+
}
|
| 603 |
+
</script>
|
| 604 |
+
{% endblock %}
|
templates/error.html
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}错误 {{ error_code }} - 股票智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container py-5">
|
| 7 |
+
<div class="row justify-content-center">
|
| 8 |
+
<div class="col-md-8">
|
| 9 |
+
<div class="card">
|
| 10 |
+
<div class="card-header bg-danger text-white">
|
| 11 |
+
<h4 class="mb-0">错误 {{ error_code }}</h4>
|
| 12 |
+
</div>
|
| 13 |
+
<div class="card-body text-center py-5">
|
| 14 |
+
<i class="fas fa-exclamation-triangle fa-5x text-danger mb-4"></i>
|
| 15 |
+
<h2>出现错误</h2>
|
| 16 |
+
<p class="lead">{{ message }}</p>
|
| 17 |
+
<div class="mt-4">
|
| 18 |
+
<a href="/" class="btn btn-primary me-2">
|
| 19 |
+
<i class="fas fa-home"></i> 返回首页
|
| 20 |
+
</a>
|
| 21 |
+
<button class="btn btn-outline-secondary" onclick="history.back()">
|
| 22 |
+
<i class="fas fa-arrow-left"></i> 返回上一页
|
| 23 |
+
</button>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
{% endblock %}
|
templates/fundamental.html
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}基本面分析 - {{ stock_code }} - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-3">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header py-2">
|
| 13 |
+
<h5 class="mb-0">基本面分析</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body py-2">
|
| 16 |
+
<form id="fundamental-form" class="row g-2">
|
| 17 |
+
<div class="col-md-4">
|
| 18 |
+
<div class="input-group input-group-sm">
|
| 19 |
+
<span class="input-group-text">股票代码</span>
|
| 20 |
+
<input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="col-md-3">
|
| 24 |
+
<div class="input-group input-group-sm">
|
| 25 |
+
<span class="input-group-text">市场</span>
|
| 26 |
+
<select class="form-select" id="market-type">
|
| 27 |
+
<option value="A" selected>A股</option>
|
| 28 |
+
<option value="HK">港股</option>
|
| 29 |
+
<option value="US">美股</option>
|
| 30 |
+
</select>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="col-md-3">
|
| 34 |
+
<button type="submit" class="btn btn-primary btn-sm w-100">
|
| 35 |
+
<i class="fas fa-search"></i> 分析
|
| 36 |
+
</button>
|
| 37 |
+
</div>
|
| 38 |
+
</form>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div id="fundamental-result" style="display: none;">
|
| 45 |
+
<div class="row g-3 mb-3">
|
| 46 |
+
<div class="col-md-6">
|
| 47 |
+
<div class="card h-100">
|
| 48 |
+
<div class="card-header py-2">
|
| 49 |
+
<h5 class="mb-0">财务概况</h5>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="card-body">
|
| 52 |
+
<div class="row mb-3">
|
| 53 |
+
<div class="col-md-7">
|
| 54 |
+
<h3 id="stock-name" class="mb-0 fs-4"></h3>
|
| 55 |
+
<p id="stock-info" class="text-muted mb-0 small"></p>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="col-md-5 text-end">
|
| 58 |
+
<span id="fundamental-score" class="badge rounded-pill score-pill"></span>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="row">
|
| 62 |
+
<div class="col-md-6">
|
| 63 |
+
<h6>估值指标</h6>
|
| 64 |
+
<ul class="list-unstyled mt-1 mb-0 small">
|
| 65 |
+
<li><span class="text-muted">PE(TTM):</span> <span id="pe-ttm"></span></li>
|
| 66 |
+
<li><span class="text-muted">PB:</span> <span id="pb"></span></li>
|
| 67 |
+
<li><span class="text-muted">PS(TTM):</span> <span id="ps-ttm"></span></li>
|
| 68 |
+
</ul>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="col-md-6">
|
| 71 |
+
<h6>盈利能力</h6>
|
| 72 |
+
<ul class="list-unstyled mt-1 mb-0 small">
|
| 73 |
+
<li><span class="text-muted">ROE:</span> <span id="roe"></span></li>
|
| 74 |
+
<li><span class="text-muted">毛利率:</span> <span id="gross-margin"></span></li>
|
| 75 |
+
<li><span class="text-muted">净利润率:</span> <span id="net-profit-margin"></span></li>
|
| 76 |
+
</ul>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="col-md-6">
|
| 83 |
+
<div class="card h-100">
|
| 84 |
+
<div class="card-header py-2">
|
| 85 |
+
<h5 class="mb-0">成长性分析</h5>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="card-body">
|
| 88 |
+
<div class="row">
|
| 89 |
+
<div class="col-md-6">
|
| 90 |
+
<h6>营收增长</h6>
|
| 91 |
+
<ul class="list-unstyled mt-1 mb-0 small">
|
| 92 |
+
<li><span class="text-muted">3年CAGR:</span> <span id="revenue-growth-3y"></span></li>
|
| 93 |
+
<li><span class="text-muted">5年CAGR:</span> <span id="revenue-growth-5y"></span></li>
|
| 94 |
+
</ul>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="col-md-6">
|
| 97 |
+
<h6>利润增长</h6>
|
| 98 |
+
<ul class="list-unstyled mt-1 mb-0 small">
|
| 99 |
+
<li><span class="text-muted">3年CAGR:</span> <span id="profit-growth-3y"></span></li>
|
| 100 |
+
<li><span class="text-muted">5年CAGR:</span> <span id="profit-growth-5y"></span></li>
|
| 101 |
+
</ul>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
<div class="mt-3">
|
| 105 |
+
<div id="growth-chart" style="height: 150px;"></div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div class="row g-3 mb-3">
|
| 113 |
+
<div class="col-md-4">
|
| 114 |
+
<div class="card h-100">
|
| 115 |
+
<div class="card-header py-2">
|
| 116 |
+
<h5 class="mb-0">估值评分</h5>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="card-body">
|
| 119 |
+
<div id="valuation-chart" style="height: 200px;"></div>
|
| 120 |
+
<p id="valuation-comment" class="small text-muted mt-2"></p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="col-md-4">
|
| 125 |
+
<div class="card h-100">
|
| 126 |
+
<div class="card-header py-2">
|
| 127 |
+
<h5 class="mb-0">财务健康评分</h5>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="card-body">
|
| 130 |
+
<div id="financial-chart" style="height: 200px;"></div>
|
| 131 |
+
<p id="financial-comment" class="small text-muted mt-2"></p>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
<div class="col-md-4">
|
| 136 |
+
<div class="card h-100">
|
| 137 |
+
<div class="card-header py-2">
|
| 138 |
+
<h5 class="mb-0">成长性评分</h5>
|
| 139 |
+
</div>
|
| 140 |
+
<div class="card-body">
|
| 141 |
+
<div id="growth-score-chart" style="height: 200px;"></div>
|
| 142 |
+
<p id="growth-comment" class="small text-muted mt-2"></p>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
{% endblock %}
|
| 150 |
+
|
| 151 |
+
{% block scripts %}
|
| 152 |
+
<script>
|
| 153 |
+
$(document).ready(function() {
|
| 154 |
+
$('#fundamental-form').submit(function(e) {
|
| 155 |
+
e.preventDefault();
|
| 156 |
+
const stockCode = $('#stock-code').val().trim();
|
| 157 |
+
const marketType = $('#market-type').val();
|
| 158 |
+
|
| 159 |
+
if (!stockCode) {
|
| 160 |
+
showError('请输入股票代码!');
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
fetchFundamentalAnalysis(stockCode);
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
function fetchFundamentalAnalysis(stockCode) {
|
| 169 |
+
showLoading();
|
| 170 |
+
|
| 171 |
+
$.ajax({
|
| 172 |
+
url: '/api/fundamental_analysis',
|
| 173 |
+
type: 'POST',
|
| 174 |
+
contentType: 'application/json',
|
| 175 |
+
data: JSON.stringify({
|
| 176 |
+
stock_code: stockCode
|
| 177 |
+
}),
|
| 178 |
+
success: function(response) {
|
| 179 |
+
hideLoading();
|
| 180 |
+
renderFundamentalAnalysis(response, stockCode);
|
| 181 |
+
$('#fundamental-result').show();
|
| 182 |
+
},
|
| 183 |
+
error: function(xhr, status, error) {
|
| 184 |
+
hideLoading();
|
| 185 |
+
let errorMsg = '获取基本面分析失败';
|
| 186 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 187 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 188 |
+
} else if (error) {
|
| 189 |
+
errorMsg += ': ' + error;
|
| 190 |
+
}
|
| 191 |
+
showError(errorMsg);
|
| 192 |
+
}
|
| 193 |
+
});
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function renderFundamentalAnalysis(data, stockCode) {
|
| 197 |
+
// 设置基本信息
|
| 198 |
+
$('#stock-name').text(data.details.indicators.stock_name || stockCode);
|
| 199 |
+
$('#stock-info').text(data.details.indicators.industry || '未知行业');
|
| 200 |
+
|
| 201 |
+
// 设置评分
|
| 202 |
+
const scoreClass = getScoreColorClass(data.total);
|
| 203 |
+
$('#fundamental-score').text(data.total).addClass(scoreClass);
|
| 204 |
+
|
| 205 |
+
// 设置估值指标
|
| 206 |
+
$('#pe-ttm').text(formatNumber(data.details.indicators.pe_ttm, 2));
|
| 207 |
+
$('#pb').text(formatNumber(data.details.indicators.pb, 2));
|
| 208 |
+
$('#ps-ttm').text(formatNumber(data.details.indicators.ps_ttm, 2));
|
| 209 |
+
|
| 210 |
+
// 设置盈利能力
|
| 211 |
+
$('#roe').text(formatPercent(data.details.indicators.roe, 2));
|
| 212 |
+
$('#gross-margin').text(formatPercent(data.details.indicators.gross_margin, 2));
|
| 213 |
+
$('#net-profit-margin').text(formatPercent(data.details.indicators.net_profit_margin, 2));
|
| 214 |
+
|
| 215 |
+
// 设置成长率
|
| 216 |
+
$('#revenue-growth-3y').text(formatPercent(data.details.growth.revenue_growth_3y, 2));
|
| 217 |
+
$('#revenue-growth-5y').text(formatPercent(data.details.growth.revenue_growth_5y, 2));
|
| 218 |
+
$('#profit-growth-3y').text(formatPercent(data.details.growth.profit_growth_3y, 2));
|
| 219 |
+
$('#profit-growth-5y').text(formatPercent(data.details.growth.profit_growth_5y, 2));
|
| 220 |
+
|
| 221 |
+
// 评论
|
| 222 |
+
$('#valuation-comment').text("估值处于行业" + (data.valuation > 20 ? "合理水平" : "偏高水平"));
|
| 223 |
+
$('#financial-comment').text("财务状况" + (data.financial_health > 30 ? "良好" : "一般"));
|
| 224 |
+
$('#growth-comment').text("成长性" + (data.growth > 20 ? "较好" : "一般"));
|
| 225 |
+
|
| 226 |
+
// 渲染图表
|
| 227 |
+
renderValuationChart(data.valuation);
|
| 228 |
+
renderFinancialChart(data.financial_health);
|
| 229 |
+
renderGrowthScoreChart(data.growth);
|
| 230 |
+
renderGrowthChart(data.details.growth);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function renderValuationChart(score) {
|
| 234 |
+
const options = {
|
| 235 |
+
series: [score],
|
| 236 |
+
chart: {
|
| 237 |
+
height: 200,
|
| 238 |
+
type: 'radialBar',
|
| 239 |
+
},
|
| 240 |
+
plotOptions: {
|
| 241 |
+
radialBar: {
|
| 242 |
+
hollow: {
|
| 243 |
+
size: '70%',
|
| 244 |
+
},
|
| 245 |
+
dataLabels: {
|
| 246 |
+
name: {
|
| 247 |
+
fontSize: '22px',
|
| 248 |
+
},
|
| 249 |
+
value: {
|
| 250 |
+
fontSize: '16px',
|
| 251 |
+
},
|
| 252 |
+
total: {
|
| 253 |
+
show: true,
|
| 254 |
+
label: '估值',
|
| 255 |
+
formatter: function() {
|
| 256 |
+
return score;
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
colors: ['#1ab7ea'],
|
| 263 |
+
labels: ['估值'],
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
const chart = new ApexCharts(document.querySelector("#valuation-chart"), options);
|
| 267 |
+
chart.render();
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
function renderFinancialChart(score) {
|
| 271 |
+
const options = {
|
| 272 |
+
series: [score],
|
| 273 |
+
chart: {
|
| 274 |
+
height: 200,
|
| 275 |
+
type: 'radialBar',
|
| 276 |
+
},
|
| 277 |
+
plotOptions: {
|
| 278 |
+
radialBar: {
|
| 279 |
+
hollow: {
|
| 280 |
+
size: '70%',
|
| 281 |
+
},
|
| 282 |
+
dataLabels: {
|
| 283 |
+
name: {
|
| 284 |
+
fontSize: '22px',
|
| 285 |
+
},
|
| 286 |
+
value: {
|
| 287 |
+
fontSize: '16px',
|
| 288 |
+
},
|
| 289 |
+
total: {
|
| 290 |
+
show: true,
|
| 291 |
+
label: '财务',
|
| 292 |
+
formatter: function() {
|
| 293 |
+
return score;
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
},
|
| 299 |
+
colors: ['#20E647'],
|
| 300 |
+
labels: ['财务'],
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
const chart = new ApexCharts(document.querySelector("#financial-chart"), options);
|
| 304 |
+
chart.render();
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function renderGrowthScoreChart(score) {
|
| 308 |
+
const options = {
|
| 309 |
+
series: [score],
|
| 310 |
+
chart: {
|
| 311 |
+
height: 200,
|
| 312 |
+
type: 'radialBar',
|
| 313 |
+
},
|
| 314 |
+
plotOptions: {
|
| 315 |
+
radialBar: {
|
| 316 |
+
hollow: {
|
| 317 |
+
size: '70%',
|
| 318 |
+
},
|
| 319 |
+
dataLabels: {
|
| 320 |
+
name: {
|
| 321 |
+
fontSize: '22px',
|
| 322 |
+
},
|
| 323 |
+
value: {
|
| 324 |
+
fontSize: '16px',
|
| 325 |
+
},
|
| 326 |
+
total: {
|
| 327 |
+
show: true,
|
| 328 |
+
label: '成长',
|
| 329 |
+
formatter: function() {
|
| 330 |
+
return score;
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
},
|
| 336 |
+
colors: ['#F9CE1D'],
|
| 337 |
+
labels: ['成长'],
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
const chart = new ApexCharts(document.querySelector("#growth-score-chart"), options);
|
| 341 |
+
chart.render();
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
function renderGrowthChart(growthData) {
|
| 345 |
+
const options = {
|
| 346 |
+
series: [{
|
| 347 |
+
name: '营收增长率',
|
| 348 |
+
data: [
|
| 349 |
+
growthData.revenue_growth_3y || 0,
|
| 350 |
+
growthData.revenue_growth_5y || 0
|
| 351 |
+
]
|
| 352 |
+
}, {
|
| 353 |
+
name: '利润增长率',
|
| 354 |
+
data: [
|
| 355 |
+
growthData.profit_growth_3y || 0,
|
| 356 |
+
growthData.profit_growth_5y || 0
|
| 357 |
+
]
|
| 358 |
+
}],
|
| 359 |
+
chart: {
|
| 360 |
+
type: 'bar',
|
| 361 |
+
height: 150,
|
| 362 |
+
toolbar: {
|
| 363 |
+
show: false
|
| 364 |
+
}
|
| 365 |
+
},
|
| 366 |
+
plotOptions: {
|
| 367 |
+
bar: {
|
| 368 |
+
horizontal: false,
|
| 369 |
+
columnWidth: '55%',
|
| 370 |
+
endingShape: 'rounded'
|
| 371 |
+
},
|
| 372 |
+
},
|
| 373 |
+
dataLabels: {
|
| 374 |
+
enabled: false
|
| 375 |
+
},
|
| 376 |
+
stroke: {
|
| 377 |
+
show: true,
|
| 378 |
+
width: 2,
|
| 379 |
+
colors: ['transparent']
|
| 380 |
+
},
|
| 381 |
+
xaxis: {
|
| 382 |
+
categories: ['3年CAGR', '5年CAGR'],
|
| 383 |
+
},
|
| 384 |
+
yaxis: {
|
| 385 |
+
title: {
|
| 386 |
+
text: '百分比 (%)'
|
| 387 |
+
}
|
| 388 |
+
},
|
| 389 |
+
fill: {
|
| 390 |
+
opacity: 1
|
| 391 |
+
},
|
| 392 |
+
tooltip: {
|
| 393 |
+
y: {
|
| 394 |
+
formatter: function(val) {
|
| 395 |
+
return val + "%"
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
const chart = new ApexCharts(document.querySelector("#growth-chart"), options);
|
| 402 |
+
chart.render();
|
| 403 |
+
}
|
| 404 |
+
</script>
|
| 405 |
+
{% endblock %}
|
templates/index.html
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}首页 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container py-5">
|
| 7 |
+
<div class="row mb-5">
|
| 8 |
+
<div class="col-md-8 mx-auto text-center">
|
| 9 |
+
<h1 class="display-4 mb-4">智能分析系统</h1>
|
| 10 |
+
<p class="lead mb-4">基于人工智能的多维度股票分析平台,为您提供专业的投资决策支持</p>
|
| 11 |
+
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
| 12 |
+
<a href="/dashboard" class="btn btn-primary btn-lg px-4 me-md-2">
|
| 13 |
+
<i class="fas fa-chart-line"></i> 开始分析
|
| 14 |
+
</a>
|
| 15 |
+
<button type="button" class="btn btn-outline-secondary btn-lg px-4" data-bs-toggle="modal" data-bs-target="#quickAnalysisModal">
|
| 16 |
+
<i class="fas fa-search"></i> 快速分析
|
| 17 |
+
</button>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="row mb-5">
|
| 23 |
+
<div class="col-md-4 mb-4">
|
| 24 |
+
<div class="card h-100">
|
| 25 |
+
<div class="card-body text-center py-5">
|
| 26 |
+
<i class="fas fa-chart-pie fa-3x text-primary mb-3"></i>
|
| 27 |
+
<h4>多维度分析</h4>
|
| 28 |
+
<p class="text-muted">技术面、基本面、资金面多角度评估,全方位解读股票价值</p>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="col-md-4 mb-4">
|
| 33 |
+
<div class="card h-100">
|
| 34 |
+
<div class="card-body text-center py-5">
|
| 35 |
+
<i class="fas fa-robot fa-3x text-primary mb-3"></i>
|
| 36 |
+
<h4>AI智能辅助</h4>
|
| 37 |
+
<p class="text-muted">基于先进算法的智能分析,提供专业级投资建议和趋势预测</p>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="col-md-4 mb-4">
|
| 42 |
+
<div class="card h-100">
|
| 43 |
+
<div class="card-body text-center py-5">
|
| 44 |
+
<i class="fas fa-briefcase fa-3x text-primary mb-3"></i>
|
| 45 |
+
<h4>投资组合管理</h4>
|
| 46 |
+
<p class="text-muted">智能构建和评估投资组合,优化资产配置,控制风险</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- 替换index.html中的功能列表部分 -->
|
| 53 |
+
<div class="row mb-5">
|
| 54 |
+
<div class="col-md-10 mx-auto">
|
| 55 |
+
<div class="card">
|
| 56 |
+
<div class="card-header">
|
| 57 |
+
<h5 class="mb-0">主要功能</h5>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="card-body">
|
| 60 |
+
<div class="row">
|
| 61 |
+
<div class="col-md-6">
|
| 62 |
+
<ul class="list-group list-group-flush">
|
| 63 |
+
<li class="list-group-item d-flex align-items-center">
|
| 64 |
+
<i class="fas fa-chart-line text-primary me-3"></i>
|
| 65 |
+
<div>
|
| 66 |
+
<strong>单股智能分析</strong>
|
| 67 |
+
<p class="text-muted mb-0">多维度评估股票表现,提供精准分析</p>
|
| 68 |
+
</div>
|
| 69 |
+
</li>
|
| 70 |
+
<li class="list-group-item d-flex align-items-center">
|
| 71 |
+
<i class="fas fa-search text-primary me-3"></i>
|
| 72 |
+
<div>
|
| 73 |
+
<strong>市场扫描</strong>
|
| 74 |
+
<p class="text-muted mb-0">筛选高评分股票,发现潜在投资机会</p>
|
| 75 |
+
</div>
|
| 76 |
+
</li>
|
| 77 |
+
<li class="list-group-item d-flex align-items-center">
|
| 78 |
+
<i class="fas fa-robot text-primary me-3"></i>
|
| 79 |
+
<div>
|
| 80 |
+
<strong>AI智能问答</strong>
|
| 81 |
+
<p class="text-muted mb-0">提供智能分析解读,解答投资疑问</p>
|
| 82 |
+
</div>
|
| 83 |
+
</li>
|
| 84 |
+
<li class="list-group-item d-flex align-items-center">
|
| 85 |
+
<i class="fas fa-file-invoice-dollar text-success me-3"></i>
|
| 86 |
+
<div>
|
| 87 |
+
<strong>基本面分析</strong>
|
| 88 |
+
<p class="text-muted mb-0">透视公司财务健康和增长潜力</p>
|
| 89 |
+
</div>
|
| 90 |
+
</li>
|
| 91 |
+
<li class="list-group-item d-flex align-items-center">
|
| 92 |
+
<i class="fas fa-money-bill-wave text-info me-3"></i>
|
| 93 |
+
<div>
|
| 94 |
+
<strong>资金流向分析</strong>
|
| 95 |
+
<p class="text-muted mb-0">跟踪主力资金、北向资金和机构持仓</p>
|
| 96 |
+
</div>
|
| 97 |
+
</li>
|
| 98 |
+
</ul>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="col-md-6">
|
| 101 |
+
<ul class="list-group list-group-flush">
|
| 102 |
+
<li class="list-group-item d-flex align-items-center">
|
| 103 |
+
<i class="fas fa-briefcase text-primary me-3"></i>
|
| 104 |
+
<div>
|
| 105 |
+
<strong>投资组合分析</strong>
|
| 106 |
+
<p class="text-muted mb-0">评估组合表现,提供优化建议</p>
|
| 107 |
+
</div>
|
| 108 |
+
</li>
|
| 109 |
+
<li class="list-group-item d-flex align-items-center">
|
| 110 |
+
<i class="fas fa-exclamation-triangle text-danger me-3"></i>
|
| 111 |
+
<div>
|
| 112 |
+
<strong>风险预警</strong>
|
| 113 |
+
<p class="text-muted mb-0">监控技术指标,及时预警潜在风险</p>
|
| 114 |
+
</div>
|
| 115 |
+
</li>
|
| 116 |
+
<li class="list-group-item d-flex align-items-center">
|
| 117 |
+
<i class="fas fa-lightbulb text-warning me-3"></i>
|
| 118 |
+
<div>
|
| 119 |
+
<strong>情景预测</strong>
|
| 120 |
+
<p class="text-muted mb-0">分析多种市场情景,提前布局应对</p>
|
| 121 |
+
</div>
|
| 122 |
+
</li>
|
| 123 |
+
<li class="list-group-item d-flex align-items-center">
|
| 124 |
+
<i class="fas fa-industry text-secondary me-3"></i>
|
| 125 |
+
<div>
|
| 126 |
+
<strong>行业指数分析</strong>
|
| 127 |
+
<p class="text-muted mb-0">洞察行业格局和板块轮动机会</p>
|
| 128 |
+
</div>
|
| 129 |
+
</li>
|
| 130 |
+
<li class="list-group-item d-flex align-items-center">
|
| 131 |
+
<i class="fas fa-chart-area text-primary me-3"></i>
|
| 132 |
+
<div>
|
| 133 |
+
<strong>可视化图表</strong>
|
| 134 |
+
<p class="text-muted mb-0">直观展示分析结果,辅助决策</p>
|
| 135 |
+
</div>
|
| 136 |
+
</li>
|
| 137 |
+
</ul>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- 在index.html的功能卡片部分(现有的卡片之后)添加新卡片 -->
|
| 146 |
+
<div class="row mb-5">
|
| 147 |
+
<!-- 保留原有的三个卡片 -->
|
| 148 |
+
<div class="col-md-4 mb-4">
|
| 149 |
+
<div class="card h-100">
|
| 150 |
+
<div class="card-body text-center py-5">
|
| 151 |
+
<i class="fas fa-chart-pie fa-3x text-primary mb-3"></i>
|
| 152 |
+
<h4>多维度分析</h4>
|
| 153 |
+
<p class="text-muted">技术面、基本面、资金面多角度评估,全方位解读股票价值</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="col-md-4 mb-4">
|
| 158 |
+
<div class="card h-100">
|
| 159 |
+
<div class="card-body text-center py-5">
|
| 160 |
+
<i class="fas fa-robot fa-3x text-primary mb-3"></i>
|
| 161 |
+
<h4>AI智能辅助</h4>
|
| 162 |
+
<p class="text-muted">基于先进算法的智能分析,提供专业级投资建议和趋势预测</p>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="col-md-4 mb-4">
|
| 167 |
+
<div class="card h-100">
|
| 168 |
+
<div class="card-body text-center py-5">
|
| 169 |
+
<i class="fas fa-briefcase fa-3x text-primary mb-3"></i>
|
| 170 |
+
<h4>投资组合管理</h4>
|
| 171 |
+
<p class="text-muted">智能构建和评估投资组合,优化资产配置,控制风险</p>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- 添加新的功能卡片行 -->
|
| 178 |
+
<div class="row mb-5">
|
| 179 |
+
<div class="col-md-4 mb-4">
|
| 180 |
+
<div class="card h-100">
|
| 181 |
+
<div class="card-body text-center py-5">
|
| 182 |
+
<i class="fas fa-file-invoice-dollar fa-3x text-success mb-3"></i>
|
| 183 |
+
<h4>基本面透视</h4>
|
| 184 |
+
<p class="text-muted">财务指标分析、估值评估、成长性预测,揭示公司内在价值</p>
|
| 185 |
+
<a href="/fundamental" class="btn btn-outline-success mt-2">开始分析</a>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div class="col-md-4 mb-4">
|
| 190 |
+
<div class="card h-100">
|
| 191 |
+
<div class="card-body text-center py-5">
|
| 192 |
+
<i class="fas fa-money-bill-wave fa-3x text-info mb-3"></i>
|
| 193 |
+
<h4>资金流向分析</h4>
|
| 194 |
+
<p class="text-muted">主力资金、北向资金、机构持仓变化,把握资金动向</p>
|
| 195 |
+
<a href="/capital_flow" class="btn btn-outline-info mt-2">查看资金</a>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="col-md-4 mb-4">
|
| 200 |
+
<div class="card h-100">
|
| 201 |
+
<div class="card-body text-center py-5">
|
| 202 |
+
<i class="fas fa-crystal-ball fa-3x text-warning mb-3"></i>
|
| 203 |
+
<h4>多情景预测</h4>
|
| 204 |
+
<p class="text-muted">乐观、中性、悲观三种市场情景,助您提前布局应对不同市场环境</p>
|
| 205 |
+
<a href="/scenario_predict" class="btn btn-outline-warning mt-2">查看预测</a>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<div class="row mb-5">
|
| 212 |
+
<div class="col-md-4 mb-4">
|
| 213 |
+
<div class="card h-100">
|
| 214 |
+
<div class="card-body text-center py-5">
|
| 215 |
+
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
| 216 |
+
<h4>风险监控</h4>
|
| 217 |
+
<p class="text-muted">多维度风险预警,帮助您及时发现潜在风险,保护投资安全</p>
|
| 218 |
+
<a href="/risk_monitor" class="btn btn-outline-danger mt-2">查看风险</a>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
<div class="col-md-4 mb-4">
|
| 223 |
+
<div class="card h-100">
|
| 224 |
+
<div class="card-body text-center py-5">
|
| 225 |
+
<i class="fas fa-question-circle fa-3x text-purple mb-3"></i>
|
| 226 |
+
<h4>智能问答</h4>
|
| 227 |
+
<p class="text-muted">AI助手随时回答您关于股票的各种问题,专业知识触手可及</p>
|
| 228 |
+
<a href="/qa" class="btn btn-outline-primary mt-2">开始提问</a>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="col-md-4 mb-4">
|
| 233 |
+
<div class="card h-100">
|
| 234 |
+
<div class="card-body text-center py-5">
|
| 235 |
+
<i class="fas fa-industry fa-3x text-secondary mb-3"></i>
|
| 236 |
+
<h4>行业指数分析</h4>
|
| 237 |
+
<p class="text-muted">行业整体分析与板块轮动把握,发现行业机会,洞察市场格局</p>
|
| 238 |
+
<a href="/industry_analysis" class="btn btn-outline-secondary mt-2">查看行业</a>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<!-- 替换index.html底部的导航按钮区域 -->
|
| 245 |
+
<div class="row">
|
| 246 |
+
<div class="col-md-10 mx-auto text-center">
|
| 247 |
+
<h4 class="mb-4">开始使用智能分析系统</h4>
|
| 248 |
+
<div class="d-grid gap-2 d-md-flex justify-content-md-center flex-wrap">
|
| 249 |
+
<a href="/dashboard" class="btn btn-primary btn-lg px-4 me-md-2 mb-2">
|
| 250 |
+
<i class="fas fa-chart-line"></i> 智能仪表盘
|
| 251 |
+
</a>
|
| 252 |
+
<a href="/fundamental" class="btn btn-success btn-lg px-4 me-md-2 mb-2">
|
| 253 |
+
<i class="fas fa-file-invoice-dollar"></i> 基本面分析
|
| 254 |
+
</a>
|
| 255 |
+
<a href="/capital_flow" class="btn btn-info btn-lg px-4 me-md-2 mb-2">
|
| 256 |
+
<i class="fas fa-money-bill-wave"></i> 资金流向
|
| 257 |
+
</a>
|
| 258 |
+
<a href="/market_scan" class="btn btn-secondary btn-lg px-4 me-md-2 mb-2">
|
| 259 |
+
<i class="fas fa-search"></i> 市场扫描
|
| 260 |
+
</a>
|
| 261 |
+
<a href="/scenario_predict" class="btn btn-warning btn-lg px-4 me-md-2 mb-2">
|
| 262 |
+
<i class="fas fa-crystal-ball"></i> 情景预测
|
| 263 |
+
</a>
|
| 264 |
+
<a href="/portfolio" class="btn btn-dark btn-lg px-4 me-md-2 mb-2">
|
| 265 |
+
<i class="fas fa-briefcase"></i> 投资组合
|
| 266 |
+
</a>
|
| 267 |
+
<a href="/qa" class="btn btn-primary btn-lg px-4 me-md-2 mb-2">
|
| 268 |
+
<i class="fas fa-question-circle"></i> 智能问答
|
| 269 |
+
</a>
|
| 270 |
+
<a href="/risk_monitor" class="btn btn-danger btn-lg px-4 me-md-2 mb-2">
|
| 271 |
+
<i class="fas fa-exclamation-triangle"></i> 风险监控
|
| 272 |
+
</a>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<!-- 快速分析模态框 -->
|
| 279 |
+
<div class="modal fade" id="quickAnalysisModal" tabindex="-1" aria-labelledby="quickAnalysisModalLabel" aria-hidden="true">
|
| 280 |
+
<div class="modal-dialog">
|
| 281 |
+
<div class="modal-content">
|
| 282 |
+
<div class="modal-header">
|
| 283 |
+
<h5 class="modal-title" id="quickAnalysisModalLabel">快速股票分析</h5>
|
| 284 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 285 |
+
</div>
|
| 286 |
+
<div class="modal-body">
|
| 287 |
+
<form id="quick-analysis-form">
|
| 288 |
+
<div class="mb-3">
|
| 289 |
+
<label for="quick-stock-code" class="form-label">股票代码</label>
|
| 290 |
+
<input type="text" class="form-control" id="quick-stock-code" placeholder="例如: 600519" required>
|
| 291 |
+
</div>
|
| 292 |
+
<div class="mb-3">
|
| 293 |
+
<label for="quick-market-type" class="form-label">市场类型</label>
|
| 294 |
+
<select class="form-select" id="quick-market-type">
|
| 295 |
+
<option value="A" selected>A股</option>
|
| 296 |
+
<option value="HK">港股</option>
|
| 297 |
+
<option value="US">美股</option>
|
| 298 |
+
</select>
|
| 299 |
+
</div>
|
| 300 |
+
</form>
|
| 301 |
+
</div>
|
| 302 |
+
<div class="modal-footer">
|
| 303 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
| 304 |
+
<button type="button" class="btn btn-primary" id="quick-analysis-btn">开始分析</button>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
{% endblock %}
|
| 310 |
+
|
| 311 |
+
{% block scripts %}
|
| 312 |
+
<script>
|
| 313 |
+
$(document).ready(function() {
|
| 314 |
+
// 快速分析按钮点击事件
|
| 315 |
+
$('#quick-analysis-btn').click(function() {
|
| 316 |
+
const stockCode = $('#quick-stock-code').val().trim();
|
| 317 |
+
const marketType = $('#quick-market-type').val();
|
| 318 |
+
|
| 319 |
+
if (!stockCode) {
|
| 320 |
+
alert('请输入股票代码');
|
| 321 |
+
return;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 跳转到股票详情页
|
| 325 |
+
window.location.href = `/stock_detail/${stockCode}?market_type=${marketType}`;
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
// 回车键提交表单
|
| 329 |
+
$('#quick-stock-code').keypress(function(e) {
|
| 330 |
+
if (e.which === 13) {
|
| 331 |
+
$('#quick-analysis-btn').click();
|
| 332 |
+
return false;
|
| 333 |
+
}
|
| 334 |
+
});
|
| 335 |
+
});
|
| 336 |
+
</script>
|
| 337 |
+
{% endblock %}
|
templates/industry_analysis.html
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}行业分析 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-3">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header py-2">
|
| 13 |
+
<h5 class="mb-0">行业资金流向分析</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body py-2">
|
| 16 |
+
<form id="industry-form" class="row g-2">
|
| 17 |
+
<div class="col-md-3">
|
| 18 |
+
<div class="input-group input-group-sm">
|
| 19 |
+
<span class="input-group-text">周期</span>
|
| 20 |
+
<select class="form-select" id="fund-flow-period">
|
| 21 |
+
<option value="即时" selected>即时</option>
|
| 22 |
+
<option value="3日排行">3日排行</option>
|
| 23 |
+
<option value="5日排行">5日排行</option>
|
| 24 |
+
<option value="10日排行">10日排行</option>
|
| 25 |
+
<option value="20日排行">20日排行</option>
|
| 26 |
+
</select>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="col-md-3">
|
| 30 |
+
<div class="input-group input-group-sm">
|
| 31 |
+
<span class="input-group-text">行业</span>
|
| 32 |
+
<select class="form-select" id="industry-selector">
|
| 33 |
+
<option value="">-- 选择行业 --</option>
|
| 34 |
+
<!-- 行业选项将通过JS动态填充 -->
|
| 35 |
+
</select>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="col-md-2">
|
| 39 |
+
<button type="submit" class="btn btn-primary btn-sm w-100">
|
| 40 |
+
<i class="fas fa-search"></i> 分析
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
</form>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div id="loading-panel" class="text-center py-5" style="display: none;">
|
| 50 |
+
<div class="spinner-border text-primary" role="status">
|
| 51 |
+
<span class="visually-hidden">Loading...</span>
|
| 52 |
+
</div>
|
| 53 |
+
<p class="mt-3 mb-0">正在获取行业数据...</p>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<!-- 行业资金流向概览 -->
|
| 57 |
+
<div id="industry-overview" class="row g-3 mb-3" style="display: none;">
|
| 58 |
+
<div class="col-12">
|
| 59 |
+
<div class="card">
|
| 60 |
+
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
| 61 |
+
<h5 class="mb-0">行业资金流向概览</h5>
|
| 62 |
+
<div class="d-flex">
|
| 63 |
+
<span id="period-badge" class="badge bg-primary ms-2">即时</span>
|
| 64 |
+
<button class="btn btn-sm btn-outline-primary ms-2" id="export-btn">
|
| 65 |
+
<i class="fas fa-download"></i> 导出数据
|
| 66 |
+
</button>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="card-body p-0">
|
| 70 |
+
<div class="table-responsive">
|
| 71 |
+
<table class="table table-sm table-striped table-hover mb-0">
|
| 72 |
+
<thead>
|
| 73 |
+
<tr>
|
| 74 |
+
<th>序号</th>
|
| 75 |
+
<th>行业</th>
|
| 76 |
+
<th>行业指数</th>
|
| 77 |
+
<th>涨跌幅</th>
|
| 78 |
+
<th>流入资金(亿)</th>
|
| 79 |
+
<th>流出资金(亿)</th>
|
| 80 |
+
<th>净额(亿)</th>
|
| 81 |
+
<th>公司家数</th>
|
| 82 |
+
<th>领涨股</th>
|
| 83 |
+
<th>操作</th>
|
| 84 |
+
</tr>
|
| 85 |
+
</thead>
|
| 86 |
+
<tbody id="industry-table">
|
| 87 |
+
<!-- 行业资金流向数据将在JS中动态填充 -->
|
| 88 |
+
</tbody>
|
| 89 |
+
</table>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<!-- 行业详细分析 -->
|
| 97 |
+
<div id="industry-detail" class="row g-3 mb-3" style="display: none;">
|
| 98 |
+
<div class="col-md-6">
|
| 99 |
+
<div class="card h-100">
|
| 100 |
+
<div class="card-header py-2">
|
| 101 |
+
<h5 id="industry-name" class="mb-0">行业详情</h5>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="card-body">
|
| 104 |
+
<div class="row">
|
| 105 |
+
<div class="col-md-6">
|
| 106 |
+
<h6>行业概况</h6>
|
| 107 |
+
<p><span class="text-muted">行业指数:</span> <span id="industry-index" class="fw-bold"></span></p>
|
| 108 |
+
<p><span class="text-muted">涨跌幅:</span> <span id="industry-change" class="fw-bold"></span></p>
|
| 109 |
+
<p><span class="text-muted">公司家数:</span> <span id="industry-company-count" class="fw-bold"></span></p>
|
| 110 |
+
</div>
|
| 111 |
+
<div class="col-md-6">
|
| 112 |
+
<h6>资金流向</h6>
|
| 113 |
+
<p><span class="text-muted">流入资金:</span> <span id="industry-inflow" class="fw-bold"></span></p>
|
| 114 |
+
<p><span class="text-muted">流出资金:</span> <span id="industry-outflow" class="fw-bold"></span></p>
|
| 115 |
+
<p><span class="text-muted">净额:</span> <span id="industry-net-flow" class="fw-bold"></span></p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="mt-3">
|
| 119 |
+
<div id="industry-flow-chart" style="height: 200px;"></div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="col-md-6">
|
| 126 |
+
<div class="card h-100">
|
| 127 |
+
<div class="card-header py-2">
|
| 128 |
+
<h5 class="mb-0">行业评分</h5>
|
| 129 |
+
</div>
|
| 130 |
+
<div class="card-body">
|
| 131 |
+
<div class="row">
|
| 132 |
+
<div class="col-md-5">
|
| 133 |
+
<div id="industry-score-chart" style="height: 150px;"></div>
|
| 134 |
+
<h4 id="industry-score" class="text-center mt-2">--</h4>
|
| 135 |
+
<p class="text-muted text-center">综合评分</p>
|
| 136 |
+
</div>
|
| 137 |
+
<div class="col-md-7">
|
| 138 |
+
<div class="mb-3">
|
| 139 |
+
<div class="d-flex justify-content-between mb-1">
|
| 140 |
+
<span>技术面</span>
|
| 141 |
+
<span id="technical-score">--/40</span>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="progress">
|
| 144 |
+
<div id="technical-progress" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="mb-3">
|
| 148 |
+
<div class="d-flex justify-content-between mb-1">
|
| 149 |
+
<span>基本面</span>
|
| 150 |
+
<span id="fundamental-score">--/40</span>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="progress">
|
| 153 |
+
<div id="fundamental-progress" class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="mb-3">
|
| 157 |
+
<div class="d-flex justify-content-between mb-1">
|
| 158 |
+
<span>资金面</span>
|
| 159 |
+
<span id="capital-flow-score">--/20</span>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="progress">
|
| 162 |
+
<div id="capital-flow-progress" class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="mt-3">
|
| 168 |
+
<h6>投资建议</h6>
|
| 169 |
+
<p id="industry-recommendation" class="mb-0"></p>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<!-- 行业成分股表现 -->
|
| 177 |
+
<div id="industry-stocks" class="row g-3 mb-3" style="display: none;">
|
| 178 |
+
<div class="col-12">
|
| 179 |
+
<div class="card">
|
| 180 |
+
<div class="card-header py-2">
|
| 181 |
+
<h5 class="mb-0">行业成分股表现</h5>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="card-body p-0">
|
| 184 |
+
<div class="table-responsive">
|
| 185 |
+
<table class="table table-sm table-striped table-hover mb-0">
|
| 186 |
+
<thead>
|
| 187 |
+
<tr>
|
| 188 |
+
<th>代码</th>
|
| 189 |
+
<th>名称</th>
|
| 190 |
+
<th>最新价</th>
|
| 191 |
+
<th>涨跌幅</th>
|
| 192 |
+
<th>成交量</th>
|
| 193 |
+
<th>成交额(万)</th>
|
| 194 |
+
<th>换手率</th>
|
| 195 |
+
<th>评分</th>
|
| 196 |
+
<th>操作</th>
|
| 197 |
+
</tr>
|
| 198 |
+
</thead>
|
| 199 |
+
<tbody id="industry-stocks-table">
|
| 200 |
+
<!-- 行业成分股数据将在JS中动态填充 -->
|
| 201 |
+
</tbody>
|
| 202 |
+
</table>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<!-- 行业对比分析 -->
|
| 210 |
+
<div class="row g-3 mb-3">
|
| 211 |
+
<div class="col-12">
|
| 212 |
+
<div class="card">
|
| 213 |
+
<div class="card-header py-2">
|
| 214 |
+
<h5 class="mb-0">行业对比分析</h5>
|
| 215 |
+
</div>
|
| 216 |
+
<div class="card-body">
|
| 217 |
+
<ul class="nav nav-tabs" id="industry-compare-tabs" role="tablist">
|
| 218 |
+
<li class="nav-item" role="presentation">
|
| 219 |
+
<button class="nav-link active" id="fund-flow-tab" data-bs-toggle="tab" data-bs-target="#fund-flow" type="button" role="tab" aria-controls="fund-flow" aria-selected="true">资金流向</button>
|
| 220 |
+
</li>
|
| 221 |
+
<li class="nav-item" role="presentation">
|
| 222 |
+
<button class="nav-link" id="performance-tab" data-bs-toggle="tab" data-bs-target="#performance" type="button" role="tab" aria-controls="performance" aria-selected="false">行业涨跌幅</button>
|
| 223 |
+
</li>
|
| 224 |
+
</ul>
|
| 225 |
+
<div class="tab-content mt-3" id="industry-compare-tabs-content">
|
| 226 |
+
<div class="tab-pane fade show active" id="fund-flow" role="tabpanel" aria-labelledby="fund-flow-tab">
|
| 227 |
+
<div class="row">
|
| 228 |
+
<div class="col-md-6">
|
| 229 |
+
<h6>资金净流入前10行业</h6>
|
| 230 |
+
<div id="top-inflow-chart" style="height: 300px;"></div>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="col-md-6">
|
| 233 |
+
<h6>资金净流出前10行业</h6>
|
| 234 |
+
<div id="top-outflow-chart" style="height: 300px;"></div>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
<div class="tab-pane fade" id="performance" role="tabpanel" aria-labelledby="performance-tab">
|
| 239 |
+
<div class="row">
|
| 240 |
+
<div class="col-md-6">
|
| 241 |
+
<h6>涨幅前10行业</h6>
|
| 242 |
+
<div id="top-gainers-chart" style="height: 300px;"></div>
|
| 243 |
+
</div>
|
| 244 |
+
<div class="col-md-6">
|
| 245 |
+
<h6>跌幅前10行业</h6>
|
| 246 |
+
<div id="top-losers-chart" style="height: 300px;"></div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
{% endblock %}
|
| 257 |
+
|
| 258 |
+
{% block scripts %}
|
| 259 |
+
<script>
|
| 260 |
+
|
| 261 |
+
$(document).ready(function() {
|
| 262 |
+
// 加载行业资金流向数据
|
| 263 |
+
loadIndustryFundFlow();
|
| 264 |
+
|
| 265 |
+
$('#industry-form').submit(function(e) {
|
| 266 |
+
e.preventDefault();
|
| 267 |
+
|
| 268 |
+
// 获取选择的行业
|
| 269 |
+
const industry = $('#industry-selector').val();
|
| 270 |
+
|
| 271 |
+
if (!industry) {
|
| 272 |
+
showError('请选择行业名称');
|
| 273 |
+
return;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// 分析行业
|
| 277 |
+
loadIndustryDetail(industry);
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
// 资金流向周期切换
|
| 281 |
+
$('#fund-flow-period').change(function() {
|
| 282 |
+
const period = $(this).val();
|
| 283 |
+
loadIndustryFundFlow(period);
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
// 导出按钮点击事件
|
| 287 |
+
$('#export-btn').click(function() {
|
| 288 |
+
exportToCSV();
|
| 289 |
+
});
|
| 290 |
+
});
|
| 291 |
+
|
| 292 |
+
function analyzeIndustry(industry) {
|
| 293 |
+
$('#loading-panel').show();
|
| 294 |
+
$('#industry-result').hide();
|
| 295 |
+
|
| 296 |
+
// 1. 获取行业详情
|
| 297 |
+
$.ajax({
|
| 298 |
+
url: `/api/industry_detail?industry=${encodeURIComponent(industry)}`,
|
| 299 |
+
type: 'GET',
|
| 300 |
+
success: function(industryDetail) {
|
| 301 |
+
console.log("Industry detail loaded successfully:", industryDetail);
|
| 302 |
+
|
| 303 |
+
// 2. 获取行业成分股
|
| 304 |
+
$.ajax({
|
| 305 |
+
url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
|
| 306 |
+
type: 'GET',
|
| 307 |
+
success: function(stocksResponse) {
|
| 308 |
+
console.log("Industry stocks loaded successfully:", stocksResponse);
|
| 309 |
+
|
| 310 |
+
$('#loading-panel').hide();
|
| 311 |
+
|
| 312 |
+
// 渲染行业详情和成分股
|
| 313 |
+
renderIndustryDetail(industryDetail);
|
| 314 |
+
renderIndustryStocks(stocksResponse);
|
| 315 |
+
|
| 316 |
+
$('#industry-detail').show();
|
| 317 |
+
$('#industry-stocks').show();
|
| 318 |
+
},
|
| 319 |
+
error: function(xhr, status, error) {
|
| 320 |
+
$('#loading-panel').hide();
|
| 321 |
+
console.error("Error loading industry stocks:", error);
|
| 322 |
+
showError('获取行业成分股失败: ' + (xhr.responseJSON?.error || error));
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
},
|
| 326 |
+
error: function(xhr, status, error) {
|
| 327 |
+
$('#loading-panel').hide();
|
| 328 |
+
console.error("Error loading industry detail:", error);
|
| 329 |
+
showError('获取行业详情失败: ' + (xhr.responseJSON?.error || error));
|
| 330 |
+
}
|
| 331 |
+
});
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// 加载行业资金流向数据
|
| 335 |
+
function loadIndustryFundFlow(period = '即时') {
|
| 336 |
+
$('#loading-panel').show();
|
| 337 |
+
$('#industry-overview').hide();
|
| 338 |
+
$('#industry-detail').hide();
|
| 339 |
+
$('#industry-stocks').hide();
|
| 340 |
+
|
| 341 |
+
$.ajax({
|
| 342 |
+
url: `/api/industry_fund_flow?symbol=${encodeURIComponent(period)}`,
|
| 343 |
+
type: 'GET',
|
| 344 |
+
dataType: 'json',
|
| 345 |
+
success: function(response) {
|
| 346 |
+
if (Array.isArray(response) && response.length > 0) {
|
| 347 |
+
renderIndustryFundFlow(response, period);
|
| 348 |
+
populateIndustrySelector(response);
|
| 349 |
+
|
| 350 |
+
// 加载行业对比数据
|
| 351 |
+
loadIndustryCompare();
|
| 352 |
+
|
| 353 |
+
$('#loading-panel').hide();
|
| 354 |
+
$('#industry-overview').show();
|
| 355 |
+
} else {
|
| 356 |
+
showError('获取行业资金流向数据失败:返回数据为空');
|
| 357 |
+
$('#loading-panel').hide();
|
| 358 |
+
}
|
| 359 |
+
},
|
| 360 |
+
error: function(xhr, status, error) {
|
| 361 |
+
$('#loading-panel').hide();
|
| 362 |
+
let errorMsg = '获取行业资金流向数据失败';
|
| 363 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 364 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 365 |
+
} else if (error) {
|
| 366 |
+
errorMsg += ': ' + error;
|
| 367 |
+
}
|
| 368 |
+
showError(errorMsg);
|
| 369 |
+
}
|
| 370 |
+
});
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
// 统一的行业详情加载函数
|
| 375 |
+
function loadIndustryDetail(industry) {
|
| 376 |
+
console.log(`Loading industry detail for: ${industry}`);
|
| 377 |
+
$('#loading-panel').show();
|
| 378 |
+
$('#industry-overview').hide();
|
| 379 |
+
$('#industry-detail').hide();
|
| 380 |
+
$('#industry-stocks').hide();
|
| 381 |
+
|
| 382 |
+
// 并行加载行业详情和行业成分股
|
| 383 |
+
$.when(
|
| 384 |
+
// 获取行业详情
|
| 385 |
+
$.ajax({
|
| 386 |
+
url: `/api/industry_detail?industry=${encodeURIComponent(industry)}`,
|
| 387 |
+
type: 'GET',
|
| 388 |
+
dataType: 'json'
|
| 389 |
+
}),
|
| 390 |
+
// 获取行业成分股
|
| 391 |
+
$.ajax({
|
| 392 |
+
url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
|
| 393 |
+
type: 'GET',
|
| 394 |
+
dataType: 'json'
|
| 395 |
+
})
|
| 396 |
+
).done(function(detailResponse, stocksResponse) {
|
| 397 |
+
// 处理行业详情数据
|
| 398 |
+
const industryData = detailResponse[0];
|
| 399 |
+
|
| 400 |
+
// 处理行业成分股数据
|
| 401 |
+
const stocksData = stocksResponse[0];
|
| 402 |
+
|
| 403 |
+
console.log("Industry detail loaded:", industryData);
|
| 404 |
+
console.log("Industry stocks loaded:", stocksData);
|
| 405 |
+
|
| 406 |
+
renderIndustryDetail(industryData);
|
| 407 |
+
renderIndustryStocks(stocksData);
|
| 408 |
+
|
| 409 |
+
$('#loading-panel').hide();
|
| 410 |
+
$('#industry-detail').show();
|
| 411 |
+
$('#industry-stocks').show();
|
| 412 |
+
}).fail(function(jqXHR, textStatus, errorThrown) {
|
| 413 |
+
$('#loading-panel').hide();
|
| 414 |
+
console.error("Error loading industry data:", textStatus, errorThrown);
|
| 415 |
+
let errorMsg = '获取行业数据失败';
|
| 416 |
+
try {
|
| 417 |
+
if (jqXHR.responseJSON && jqXHR.responseJSON.error) {
|
| 418 |
+
errorMsg += ': ' + jqXHR.responseJSON.error;
|
| 419 |
+
} else if (errorThrown) {
|
| 420 |
+
errorMsg += ': ' + errorThrown;
|
| 421 |
+
}
|
| 422 |
+
} catch (e) {
|
| 423 |
+
console.error("Error parsing error response:", e);
|
| 424 |
+
}
|
| 425 |
+
showError(errorMsg);
|
| 426 |
+
});
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// 加载行业对比数据
|
| 430 |
+
function loadIndustryCompare() {
|
| 431 |
+
$.ajax({
|
| 432 |
+
url: '/api/industry_compare',
|
| 433 |
+
type: 'GET',
|
| 434 |
+
dataType: 'json',
|
| 435 |
+
success: function(response) {
|
| 436 |
+
try {
|
| 437 |
+
if (response && response.results) {
|
| 438 |
+
// 按资金净流入排序
|
| 439 |
+
const sortedByNetFlow = [...response.results]
|
| 440 |
+
.filter(item => item.netFlow !== undefined)
|
| 441 |
+
.sort((a, b) => parseFloat(b.netFlow || 0) - parseFloat(a.netFlow || 0));
|
| 442 |
+
|
| 443 |
+
// 按涨跌幅排序
|
| 444 |
+
const sortedByChange = [...response.results]
|
| 445 |
+
.filter(item => item.change !== undefined)
|
| 446 |
+
.sort((a, b) => parseFloat(b.change || 0) - parseFloat(a.change || 0));
|
| 447 |
+
|
| 448 |
+
// 资金净流入前10行业
|
| 449 |
+
const topInflow = sortedByNetFlow.slice(0, 10);
|
| 450 |
+
renderBarChart('top-inflow-chart',
|
| 451 |
+
topInflow.map(item => item.industry),
|
| 452 |
+
topInflow.map(item => parseFloat(item.netFlow || 0)),
|
| 453 |
+
'资金净流入(亿)',
|
| 454 |
+
'#00E396');
|
| 455 |
+
|
| 456 |
+
// 资金净流出前10行业
|
| 457 |
+
const bottomInflow = [...sortedByNetFlow].reverse().slice(0, 10);
|
| 458 |
+
renderBarChart('top-outflow-chart',
|
| 459 |
+
bottomInflow.map(item => item.industry),
|
| 460 |
+
bottomInflow.map(item => Math.abs(parseFloat(item.netFlow || 0))),
|
| 461 |
+
'资金净流出(亿)',
|
| 462 |
+
'#FF4560');
|
| 463 |
+
|
| 464 |
+
// 涨幅前10行业
|
| 465 |
+
const topGainers = sortedByChange.slice(0, 10);
|
| 466 |
+
renderBarChart('top-gainers-chart',
|
| 467 |
+
topGainers.map(item => item.industry),
|
| 468 |
+
topGainers.map(item => parseFloat(item.change || 0)),
|
| 469 |
+
'涨幅(%)',
|
| 470 |
+
'#00E396');
|
| 471 |
+
|
| 472 |
+
// 跌幅前10行业
|
| 473 |
+
const topLosers = [...sortedByChange].reverse().slice(0, 10);
|
| 474 |
+
renderBarChart('top-losers-chart',
|
| 475 |
+
topLosers.map(item => item.industry),
|
| 476 |
+
topLosers.map(item => Math.abs(parseFloat(item.change || 0))),
|
| 477 |
+
'跌幅(%)',
|
| 478 |
+
'#FF4560');
|
| 479 |
+
}
|
| 480 |
+
} catch (e) {
|
| 481 |
+
console.error("Error processing industry comparison data:", e);
|
| 482 |
+
}
|
| 483 |
+
},
|
| 484 |
+
error: function(xhr, status, error) {
|
| 485 |
+
console.error('获取行业对比数据失败:', error);
|
| 486 |
+
}
|
| 487 |
+
});
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
// 渲染行业资金流向表格
|
| 491 |
+
function renderIndustryFundFlow(data, period) {
|
| 492 |
+
$('#period-badge').text(period);
|
| 493 |
+
|
| 494 |
+
let html = '';
|
| 495 |
+
if (data.length === 0) {
|
| 496 |
+
html = '<tr><td colspan="10" class="text-center">暂无数据</td></tr>';
|
| 497 |
+
} else {
|
| 498 |
+
data.forEach((item, index) => {
|
| 499 |
+
const changeClass = parseFloat(item.change) >= 0 ? 'trend-up' : 'trend-down';
|
| 500 |
+
const changeIcon = parseFloat(item.change) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 501 |
+
|
| 502 |
+
const netFlowClass = parseFloat(item.netFlow) >= 0 ? 'trend-up' : 'trend-down';
|
| 503 |
+
const netFlowIcon = parseFloat(item.netFlow) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 504 |
+
|
| 505 |
+
html += `
|
| 506 |
+
<tr>
|
| 507 |
+
<td>${item.rank}</td>
|
| 508 |
+
<td>
|
| 509 |
+
<a href="javascript:void(0)" onclick="loadIndustryDetail('${item.industry}')" class="industry-link">
|
| 510 |
+
${item.industry}
|
| 511 |
+
</a>
|
| 512 |
+
</td>
|
| 513 |
+
<td>${formatNumber(item.index, 2)}</td>
|
| 514 |
+
<td class="${changeClass}">${changeIcon} ${item.change}%</td>
|
| 515 |
+
<td>${formatNumber(item.inflow, 2)}</td>
|
| 516 |
+
<td>${formatNumber(item.outflow, 2)}</td>
|
| 517 |
+
<td class="${netFlowClass}">${netFlowIcon} ${formatNumber(item.netFlow, 2)}</td>
|
| 518 |
+
<td>${item.companyCount}</td>
|
| 519 |
+
<td>${item.leadingStock || '-'}</td>
|
| 520 |
+
<td>
|
| 521 |
+
<button class="btn btn-sm btn-outline-primary" onclick="loadIndustryDetail('${item.industry}')">
|
| 522 |
+
<i class="fas fa-search"></i>
|
| 523 |
+
</button>
|
| 524 |
+
</td>
|
| 525 |
+
</tr>
|
| 526 |
+
`;
|
| 527 |
+
});
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
$('#industry-table').html(html);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// 渲染行业详情
|
| 534 |
+
function renderIndustryDetail(data) {
|
| 535 |
+
if (!data) {
|
| 536 |
+
console.error("renderIndustryDetail: No data provided");
|
| 537 |
+
return;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
console.log("Rendering industry detail:", data);
|
| 541 |
+
|
| 542 |
+
// 设置基本信息
|
| 543 |
+
$('#industry-name').text(data.industry);
|
| 544 |
+
|
| 545 |
+
// 设置行业评分
|
| 546 |
+
const scoreClass = getScoreColorClass(data.score);
|
| 547 |
+
$('#industry-score').text(data.score).removeClass().addClass(scoreClass);
|
| 548 |
+
|
| 549 |
+
// 设置技术面、基本面、资金面分数 (模拟分数)
|
| 550 |
+
const technicalScore = Math.round(data.score * 0.4);
|
| 551 |
+
const fundamentalScore = Math.round(data.score * 0.4);
|
| 552 |
+
const capitalFlowScore = Math.round(data.score * 0.2);
|
| 553 |
+
|
| 554 |
+
$('#technical-score').text(`${technicalScore}/40`);
|
| 555 |
+
$('#fundamental-score').text(`${fundamentalScore}/40`);
|
| 556 |
+
$('#capital-flow-score').text(`${capitalFlowScore}/20`);
|
| 557 |
+
|
| 558 |
+
$('#technical-progress').css('width', `${technicalScore / 40 * 100}%`);
|
| 559 |
+
$('#fundamental-progress').css('width', `${fundamentalScore / 40 * 100}%`);
|
| 560 |
+
$('#capital-flow-progress').css('width', `${capitalFlowScore / 20 * 100}%`);
|
| 561 |
+
|
| 562 |
+
// 设置行业基本信息
|
| 563 |
+
$('#industry-index').text(formatNumber(data.index, 2));
|
| 564 |
+
$('#industry-company-count').text(data.companyCount);
|
| 565 |
+
|
| 566 |
+
// 设置涨跌幅
|
| 567 |
+
const changeClass = parseFloat(data.change) >= 0 ? 'trend-up' : 'trend-down';
|
| 568 |
+
const changeIcon = parseFloat(data.change) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 569 |
+
$('#industry-change').html(`<span class="${changeClass}">${changeIcon} ${data.change}%</span>`);
|
| 570 |
+
|
| 571 |
+
// 设置资金流向
|
| 572 |
+
$('#industry-inflow').text(formatNumber(data.inflow, 2) + ' 亿');
|
| 573 |
+
$('#industry-outflow').text(formatNumber(data.outflow, 2) + ' 亿');
|
| 574 |
+
|
| 575 |
+
const netFlowClass = parseFloat(data.netFlow) >= 0 ? 'trend-up' : 'trend-down';
|
| 576 |
+
const netFlowIcon = parseFloat(data.netFlow) >= 0 ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| 577 |
+
$('#industry-net-flow').html(`<span class="${netFlowClass}">${netFlowIcon} ${formatNumber(data.netFlow, 2)} 亿</span>`);
|
| 578 |
+
|
| 579 |
+
// 设置投资建议
|
| 580 |
+
$('#industry-recommendation').text(data.recommendation);
|
| 581 |
+
|
| 582 |
+
// 绘制行业评分图表
|
| 583 |
+
renderIndustryScoreChart(data.score);
|
| 584 |
+
|
| 585 |
+
// 绘制资金流向图表
|
| 586 |
+
renderIndustryFlowChart(data.flowHistory);
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
// 渲染行业成分股表格
|
| 591 |
+
function renderIndustryStocks(data) {
|
| 592 |
+
if (!data) {
|
| 593 |
+
console.error("renderIndustryStocks: No data provided");
|
| 594 |
+
return;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
console.log("Rendering industry stocks:", data);
|
| 598 |
+
|
| 599 |
+
let html = '';
|
| 600 |
+
|
| 601 |
+
if (!Array.isArray(data) || data.length === 0) {
|
| 602 |
+
html = '<tr><td colspan="9" class="text-center">暂无成分股数据</td></tr>';
|
| 603 |
+
} else {
|
| 604 |
+
data.forEach(stock => {
|
| 605 |
+
const changeClass = parseFloat(stock.change) >= 0 ? 'trend-up' : 'trend-down';
|
| 606 |
+
const changeIcon = parseFloat(stock.change) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 607 |
+
|
| 608 |
+
html += `
|
| 609 |
+
<tr>
|
| 610 |
+
<td>${stock.code}</td>
|
| 611 |
+
<td>${stock.name}</td>
|
| 612 |
+
<td>${formatNumber(stock.price, 2)}</td>
|
| 613 |
+
<td class="${changeClass}">${changeIcon} ${formatNumber(stock.change, 2)}%</td>
|
| 614 |
+
<td>${formatNumber(stock.volume, 0)}</td>
|
| 615 |
+
<td>${formatMoney(stock.turnover)}</td>
|
| 616 |
+
<td>${formatNumber(stock.turnover_rate || stock.turnoverRate, 2)}%</td>
|
| 617 |
+
<td>${stock.score ? formatNumber(stock.score, 0) : '-'}</td>
|
| 618 |
+
<td>
|
| 619 |
+
<a href="/stock_detail/${stock.code}" class="btn btn-sm btn-outline-primary">
|
| 620 |
+
<i class="fas fa-chart-line"></i>
|
| 621 |
+
</a>
|
| 622 |
+
</td>
|
| 623 |
+
</tr>
|
| 624 |
+
`;
|
| 625 |
+
});
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
$('#industry-stocks-table').html(html);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
function renderCapitalFlowChart(flowHistory) {
|
| 632 |
+
|
| 633 |
+
// 添加数据检查
|
| 634 |
+
if (!flowHistory || !Array.isArray(flowHistory) || flowHistory.length === 0) {
|
| 635 |
+
// 如果没有历史数据,显示提示信息
|
| 636 |
+
document.querySelector("#industry-flow-chart").innerHTML =
|
| 637 |
+
'<div class="text-center text-muted py-5">暂无资金流向历史数据</div>';
|
| 638 |
+
return;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
const dates = flowHistory.map(item => item.date);
|
| 642 |
+
const netFlows = flowHistory.map(item => parseFloat(item.netFlow));
|
| 643 |
+
const changes = flowHistory.map(item => parseFloat(item.change));
|
| 644 |
+
|
| 645 |
+
// 确保所有数组都有值
|
| 646 |
+
if (dates.length === 0 || netFlows.length === 0 || changes.length === 0) {
|
| 647 |
+
document.querySelector("#industry-flow-chart").innerHTML =
|
| 648 |
+
'<div class="text-center text-muted py-5">资金流向数据格式不正确</div>';
|
| 649 |
+
return;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
const options = {
|
| 653 |
+
series: [
|
| 654 |
+
{
|
| 655 |
+
name: '净流入(亿)',
|
| 656 |
+
type: 'column',
|
| 657 |
+
data: netFlows
|
| 658 |
+
},
|
| 659 |
+
{
|
| 660 |
+
name: '涨跌幅(%)',
|
| 661 |
+
type: 'line',
|
| 662 |
+
data: changes
|
| 663 |
+
}
|
| 664 |
+
],
|
| 665 |
+
chart: {
|
| 666 |
+
height: 265,
|
| 667 |
+
type: 'line',
|
| 668 |
+
toolbar: {
|
| 669 |
+
show: false
|
| 670 |
+
}
|
| 671 |
+
},
|
| 672 |
+
plotOptions: {
|
| 673 |
+
bar: {
|
| 674 |
+
borderRadius: 2,
|
| 675 |
+
dataLabels: {
|
| 676 |
+
position: 'top'
|
| 677 |
+
}
|
| 678 |
+
}
|
| 679 |
+
},
|
| 680 |
+
dataLabels: {
|
| 681 |
+
enabled: false
|
| 682 |
+
},
|
| 683 |
+
stroke: {
|
| 684 |
+
width: [0, 3]
|
| 685 |
+
},
|
| 686 |
+
colors: ['#0d6efd', '#dc3545'],
|
| 687 |
+
xaxis: {
|
| 688 |
+
categories: dates,
|
| 689 |
+
labels: {
|
| 690 |
+
formatter: function(value) {
|
| 691 |
+
return value.slice(5); // 只显示月-日
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
},
|
| 695 |
+
yaxis: [
|
| 696 |
+
{
|
| 697 |
+
title: {
|
| 698 |
+
text: '净流入(亿)',
|
| 699 |
+
style: {
|
| 700 |
+
fontSize: '12px'
|
| 701 |
+
}
|
| 702 |
+
},
|
| 703 |
+
labels: {
|
| 704 |
+
formatter: function(val) {
|
| 705 |
+
return val.toFixed(2);
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
},
|
| 709 |
+
{
|
| 710 |
+
opposite: true,
|
| 711 |
+
title: {
|
| 712 |
+
text: '涨跌幅(%)',
|
| 713 |
+
style: {
|
| 714 |
+
fontSize: '12px'
|
| 715 |
+
}
|
| 716 |
+
},
|
| 717 |
+
labels: {
|
| 718 |
+
formatter: function(val) {
|
| 719 |
+
return val.toFixed(2);
|
| 720 |
+
}
|
| 721 |
+
}
|
| 722 |
+
}
|
| 723 |
+
],
|
| 724 |
+
tooltip: {
|
| 725 |
+
shared: true,
|
| 726 |
+
intersect: false,
|
| 727 |
+
y: {
|
| 728 |
+
formatter: function(value, { seriesIndex }) {
|
| 729 |
+
if (seriesIndex === 0) {
|
| 730 |
+
return value.toFixed(2) + ' 亿';
|
| 731 |
+
}
|
| 732 |
+
return value.toFixed(2) + '%';
|
| 733 |
+
}
|
| 734 |
+
}
|
| 735 |
+
},
|
| 736 |
+
legend: {
|
| 737 |
+
position: 'top'
|
| 738 |
+
}
|
| 739 |
+
};
|
| 740 |
+
|
| 741 |
+
// 清除任何现有图表
|
| 742 |
+
document.querySelector("#industry-flow-chart").innerHTML = '';
|
| 743 |
+
const chart = new ApexCharts(document.querySelector("#industry-flow-chart"), options);
|
| 744 |
+
chart.render();
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
// 填充行业选择器
|
| 748 |
+
function populateIndustrySelector(data) {
|
| 749 |
+
let options = '<option value="">-- 选择行业 --</option>';
|
| 750 |
+
const industries = data.map(item => item.industry);
|
| 751 |
+
|
| 752 |
+
industries.forEach(industry => {
|
| 753 |
+
options += `<option value="${industry}">${industry}</option>`;
|
| 754 |
+
});
|
| 755 |
+
|
| 756 |
+
$('#industry-selector').html(options);
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
// 评分颜色类
|
| 760 |
+
function getScoreColorClass(score) {
|
| 761 |
+
if (score >= 80) return 'badge rounded-pill bg-success';
|
| 762 |
+
if (score >= 60) return 'badge rounded-pill bg-primary';
|
| 763 |
+
if (score >= 40) return 'badge rounded-pill bg-warning text-dark';
|
| 764 |
+
return 'badge rounded-pill bg-danger';
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
// 获取评分颜色
|
| 768 |
+
function getScoreColor(score) {
|
| 769 |
+
if (score >= 80) return '#28a745'; // 绿色
|
| 770 |
+
if (score >= 60) return '#007bff'; // 蓝色
|
| 771 |
+
if (score >= 40) return '#ffc107'; // 黄色
|
| 772 |
+
return '#dc3545'; // 红色
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
// 格式化金额(单位:万元)
|
| 776 |
+
function formatMoney(value) {
|
| 777 |
+
if (value === undefined || value === null) {
|
| 778 |
+
return '--';
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
value = parseFloat(value);
|
| 782 |
+
if (isNaN(value)) {
|
| 783 |
+
return '--';
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
if (value >= 100000000) {
|
| 787 |
+
return (value / 100000000).toFixed(2) + ' 亿';
|
| 788 |
+
} else if (value >= 10000) {
|
| 789 |
+
return (value / 10000).toFixed(2) + ' 万';
|
| 790 |
+
} else {
|
| 791 |
+
return value.toFixed(2);
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
// 渲染行业资金流向图表
|
| 796 |
+
function renderIndustryFlowChart(data) {
|
| 797 |
+
const options = {
|
| 798 |
+
series: [
|
| 799 |
+
{
|
| 800 |
+
name: '流入资金',
|
| 801 |
+
data: data.flowHistory.map(item => item.inflow)
|
| 802 |
+
},
|
| 803 |
+
{
|
| 804 |
+
name: '流出资金',
|
| 805 |
+
data: data.flowHistory.map(item => item.outflow)
|
| 806 |
+
},
|
| 807 |
+
{
|
| 808 |
+
name: '净流入',
|
| 809 |
+
data: data.flowHistory.map(item => item.netFlow)
|
| 810 |
+
}
|
| 811 |
+
],
|
| 812 |
+
chart: {
|
| 813 |
+
type: 'bar',
|
| 814 |
+
height: 200,
|
| 815 |
+
toolbar: {
|
| 816 |
+
show: false
|
| 817 |
+
}
|
| 818 |
+
},
|
| 819 |
+
plotOptions: {
|
| 820 |
+
bar: {
|
| 821 |
+
horizontal: false,
|
| 822 |
+
columnWidth: '55%',
|
| 823 |
+
endingShape: 'rounded'
|
| 824 |
+
},
|
| 825 |
+
},
|
| 826 |
+
dataLabels: {
|
| 827 |
+
enabled: false
|
| 828 |
+
},
|
| 829 |
+
stroke: {
|
| 830 |
+
show: true,
|
| 831 |
+
width: 2,
|
| 832 |
+
colors: ['transparent']
|
| 833 |
+
},
|
| 834 |
+
xaxis: {
|
| 835 |
+
categories: data.flowHistory.map(item => item.date)
|
| 836 |
+
},
|
| 837 |
+
yaxis: {
|
| 838 |
+
title: {
|
| 839 |
+
text: '亿元'
|
| 840 |
+
}
|
| 841 |
+
},
|
| 842 |
+
fill: {
|
| 843 |
+
opacity: 1
|
| 844 |
+
},
|
| 845 |
+
tooltip: {
|
| 846 |
+
y: {
|
| 847 |
+
formatter: function(val) {
|
| 848 |
+
return val + " 亿元";
|
| 849 |
+
}
|
| 850 |
+
}
|
| 851 |
+
},
|
| 852 |
+
colors: ['#00E396', '#FF4560', '#008FFB']
|
| 853 |
+
};
|
| 854 |
+
|
| 855 |
+
const chart = new ApexCharts(document.querySelector("#industry-flow-chart"), options);
|
| 856 |
+
chart.render();
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
// 绘制行业评分图表
|
| 860 |
+
function renderIndustryScoreChart(score) {
|
| 861 |
+
const options = {
|
| 862 |
+
series: [score],
|
| 863 |
+
chart: {
|
| 864 |
+
height: 150,
|
| 865 |
+
type: 'radialBar',
|
| 866 |
+
},
|
| 867 |
+
plotOptions: {
|
| 868 |
+
radialBar: {
|
| 869 |
+
hollow: {
|
| 870 |
+
size: '70%',
|
| 871 |
+
},
|
| 872 |
+
dataLabels: {
|
| 873 |
+
show: false
|
| 874 |
+
}
|
| 875 |
+
}
|
| 876 |
+
},
|
| 877 |
+
colors: [getScoreColor(score)],
|
| 878 |
+
stroke: {
|
| 879 |
+
lineCap: 'round'
|
| 880 |
+
}
|
| 881 |
+
};
|
| 882 |
+
|
| 883 |
+
// 清除旧图表并创建新图表
|
| 884 |
+
$('#industry-score-chart').empty();
|
| 885 |
+
const chart = new ApexCharts(document.querySelector("#industry-score-chart"), options);
|
| 886 |
+
chart.render();
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
// 绘制行业资金流向图表
|
| 890 |
+
function renderIndustryFlowChart(flowHistory) {
|
| 891 |
+
if (!flowHistory || !Array.isArray(flowHistory) || flowHistory.length === 0) {
|
| 892 |
+
console.error("renderIndustryFlowChart: Invalid flow history data");
|
| 893 |
+
return;
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
console.log("Rendering flow chart with data:", flowHistory);
|
| 897 |
+
|
| 898 |
+
const dates = flowHistory.map(item => item.date);
|
| 899 |
+
const netFlows = flowHistory.map(item => parseFloat(item.netFlow));
|
| 900 |
+
const changes = flowHistory.map(item => parseFloat(item.change));
|
| 901 |
+
|
| 902 |
+
const options = {
|
| 903 |
+
series: [
|
| 904 |
+
{
|
| 905 |
+
name: '净流入(亿)',
|
| 906 |
+
type: 'column',
|
| 907 |
+
data: netFlows
|
| 908 |
+
},
|
| 909 |
+
{
|
| 910 |
+
name: '涨跌幅(%)',
|
| 911 |
+
type: 'line',
|
| 912 |
+
data: changes
|
| 913 |
+
}
|
| 914 |
+
],
|
| 915 |
+
chart: {
|
| 916 |
+
height: 200,
|
| 917 |
+
type: 'line',
|
| 918 |
+
toolbar: {
|
| 919 |
+
show: false
|
| 920 |
+
}
|
| 921 |
+
},
|
| 922 |
+
plotOptions: {
|
| 923 |
+
bar: {
|
| 924 |
+
borderRadius: 2,
|
| 925 |
+
dataLabels: {
|
| 926 |
+
position: 'top'
|
| 927 |
+
}
|
| 928 |
+
}
|
| 929 |
+
},
|
| 930 |
+
dataLabels: {
|
| 931 |
+
enabled: false
|
| 932 |
+
},
|
| 933 |
+
stroke: {
|
| 934 |
+
width: [0, 3]
|
| 935 |
+
},
|
| 936 |
+
colors: ['#0d6efd', '#dc3545'],
|
| 937 |
+
xaxis: {
|
| 938 |
+
categories: dates,
|
| 939 |
+
labels: {
|
| 940 |
+
formatter: function(value) {
|
| 941 |
+
// Only show month-day if it's a date string
|
| 942 |
+
if (typeof value === 'string' && value.includes('-')) {
|
| 943 |
+
return value.slice(5); // 只显示月-日
|
| 944 |
+
}
|
| 945 |
+
return value;
|
| 946 |
+
}
|
| 947 |
+
}
|
| 948 |
+
},
|
| 949 |
+
yaxis: [
|
| 950 |
+
{
|
| 951 |
+
title: {
|
| 952 |
+
text: '净流入(亿)',
|
| 953 |
+
style: {
|
| 954 |
+
fontSize: '12px'
|
| 955 |
+
}
|
| 956 |
+
},
|
| 957 |
+
labels: {
|
| 958 |
+
formatter: function(val) {
|
| 959 |
+
return val.toFixed(2);
|
| 960 |
+
}
|
| 961 |
+
}
|
| 962 |
+
},
|
| 963 |
+
{
|
| 964 |
+
opposite: true,
|
| 965 |
+
title: {
|
| 966 |
+
text: '涨跌幅(%)',
|
| 967 |
+
style: {
|
| 968 |
+
fontSize: '12px'
|
| 969 |
+
}
|
| 970 |
+
},
|
| 971 |
+
labels: {
|
| 972 |
+
formatter: function(val) {
|
| 973 |
+
return val.toFixed(2);
|
| 974 |
+
}
|
| 975 |
+
}
|
| 976 |
+
}
|
| 977 |
+
],
|
| 978 |
+
tooltip: {
|
| 979 |
+
shared: true,
|
| 980 |
+
intersect: false,
|
| 981 |
+
y: {
|
| 982 |
+
formatter: function(value, { seriesIndex }) {
|
| 983 |
+
if (seriesIndex === 0) {
|
| 984 |
+
return value.toFixed(2) + ' 亿';
|
| 985 |
+
}
|
| 986 |
+
return value.toFixed(2) + '%';
|
| 987 |
+
}
|
| 988 |
+
}
|
| 989 |
+
},
|
| 990 |
+
legend: {
|
| 991 |
+
position: 'top'
|
| 992 |
+
}
|
| 993 |
+
};
|
| 994 |
+
|
| 995 |
+
// 清除旧图表并创建新图表
|
| 996 |
+
$('#industry-flow-chart').empty();
|
| 997 |
+
try {
|
| 998 |
+
const chart = new ApexCharts(document.querySelector("#industry-flow-chart"), options);
|
| 999 |
+
chart.render();
|
| 1000 |
+
} catch (e) {
|
| 1001 |
+
console.error("Error rendering flow chart:", e);
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
// 渲染行业对比图表
|
| 1006 |
+
function renderIndustryCompareCharts(data) {
|
| 1007 |
+
// 按资金净流入排序
|
| 1008 |
+
const sortedByNetFlow = [...data].sort((a, b) => b.netFlow - a.netFlow);
|
| 1009 |
+
|
| 1010 |
+
// 资金净流入前10
|
| 1011 |
+
const topInflow = sortedByNetFlow.slice(0, 10);
|
| 1012 |
+
renderBarChart('top-inflow-chart', topInflow.map(item => item.industry), topInflow.map(item => item.netFlow), '资金净流入(亿元)', '#00E396');
|
| 1013 |
+
|
| 1014 |
+
// 资金净流出前10
|
| 1015 |
+
const bottomInflow = [...sortedByNetFlow].reverse().slice(0, 10);
|
| 1016 |
+
renderBarChart('top-outflow-chart', bottomInflow.map(item => item.industry), bottomInflow.map(item => Math.abs(item.netFlow)), '资金净流出(亿元)', '#FF4560');
|
| 1017 |
+
|
| 1018 |
+
// 按涨跌幅排序
|
| 1019 |
+
const sortedByChange = [...data].sort((a, b) => parseFloat(b.change) - parseFloat(a.change));
|
| 1020 |
+
|
| 1021 |
+
// 涨幅前10
|
| 1022 |
+
const topGainers = sortedByChange.slice(0, 10);
|
| 1023 |
+
renderBarChart('top-gainers-chart', topGainers.map(item => item.industry), topGainers.map(item => parseFloat(item.change)), '涨幅(%)', '#00E396');
|
| 1024 |
+
|
| 1025 |
+
// 跌幅前10
|
| 1026 |
+
const topLosers = [...sortedByChange].reverse().slice(0, 10);
|
| 1027 |
+
renderBarChart('top-losers-chart', topLosers.map(item => item.industry), topLosers.map(item => Math.abs(parseFloat(item.change))), '跌幅(%)', '#FF4560');
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
// 通用水平条形图
|
| 1031 |
+
function renderBarChart(elementId, categories, data, title, color) {
|
| 1032 |
+
if (!categories || !data || categories.length === 0 || data.length === 0) {
|
| 1033 |
+
console.error(`renderBarChart: Invalid data for ${elementId}`);
|
| 1034 |
+
return;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
const options = {
|
| 1038 |
+
series: [{
|
| 1039 |
+
name: title,
|
| 1040 |
+
data: data
|
| 1041 |
+
}],
|
| 1042 |
+
chart: {
|
| 1043 |
+
type: 'bar',
|
| 1044 |
+
height: 300,
|
| 1045 |
+
toolbar: {
|
| 1046 |
+
show: false
|
| 1047 |
+
}
|
| 1048 |
+
},
|
| 1049 |
+
plotOptions: {
|
| 1050 |
+
bar: {
|
| 1051 |
+
horizontal: true,
|
| 1052 |
+
dataLabels: {
|
| 1053 |
+
position: 'top',
|
| 1054 |
+
},
|
| 1055 |
+
}
|
| 1056 |
+
},
|
| 1057 |
+
dataLabels: {
|
| 1058 |
+
enabled: true,
|
| 1059 |
+
offsetX: -6,
|
| 1060 |
+
style: {
|
| 1061 |
+
fontSize: '12px',
|
| 1062 |
+
colors: ['#fff']
|
| 1063 |
+
},
|
| 1064 |
+
formatter: function(val) {
|
| 1065 |
+
return val.toFixed(2);
|
| 1066 |
+
}
|
| 1067 |
+
},
|
| 1068 |
+
stroke: {
|
| 1069 |
+
show: true,
|
| 1070 |
+
width: 1,
|
| 1071 |
+
colors: ['#fff']
|
| 1072 |
+
},
|
| 1073 |
+
xaxis: {
|
| 1074 |
+
categories: categories
|
| 1075 |
+
},
|
| 1076 |
+
yaxis: {
|
| 1077 |
+
title: {
|
| 1078 |
+
text: title
|
| 1079 |
+
}
|
| 1080 |
+
},
|
| 1081 |
+
fill: {
|
| 1082 |
+
opacity: 1
|
| 1083 |
+
},
|
| 1084 |
+
colors: [color]
|
| 1085 |
+
};
|
| 1086 |
+
|
| 1087 |
+
// 清除旧图表并创建新图表
|
| 1088 |
+
$(`#${elementId}`).empty();
|
| 1089 |
+
try {
|
| 1090 |
+
const chart = new ApexCharts(document.querySelector(`#${elementId}`), options);
|
| 1091 |
+
chart.render();
|
| 1092 |
+
} catch (e) {
|
| 1093 |
+
console.error(`Error rendering chart ${elementId}:`, e);
|
| 1094 |
+
}
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
// 导出CSV
|
| 1098 |
+
function exportToCSV() {
|
| 1099 |
+
// 获取表格数据
|
| 1100 |
+
const table = document.querySelector('#industry-overview table');
|
| 1101 |
+
let csv = [];
|
| 1102 |
+
let rows = table.querySelectorAll('tr');
|
| 1103 |
+
|
| 1104 |
+
for (let i = 0; i < rows.length; i++) {
|
| 1105 |
+
let row = [], cols = rows[i].querySelectorAll('td, th');
|
| 1106 |
+
|
| 1107 |
+
for (let j = 0; j < cols.length - 1; j++) { // 跳过最后一列(操作列)
|
| 1108 |
+
// 获取单元格的文本内容,去除HTML标签
|
| 1109 |
+
let text = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/,/g, ',');
|
| 1110 |
+
row.push(text);
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
csv.push(row.join(','));
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
// 下载CSV文件
|
| 1117 |
+
const period = $('#period-badge').text();
|
| 1118 |
+
const csvString = csv.join('\n');
|
| 1119 |
+
const filename = `行业资金流向_${period}_${new Date().toISOString().slice(0, 10)}.csv`;
|
| 1120 |
+
|
| 1121 |
+
const blob = new Blob(['\uFEFF' + csvString], { type: 'text/csv;charset=utf-8;' });
|
| 1122 |
+
const link = document.createElement('a');
|
| 1123 |
+
link.href = URL.createObjectURL(blob);
|
| 1124 |
+
link.download = filename;
|
| 1125 |
+
|
| 1126 |
+
link.style.display = 'none';
|
| 1127 |
+
document.body.appendChild(link);
|
| 1128 |
+
|
| 1129 |
+
link.click();
|
| 1130 |
+
|
| 1131 |
+
document.body.removeChild(link);
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
</script>
|
| 1135 |
+
{% endblock %}
|
templates/layout.html
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
| 6 |
+
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 8 |
+
<title>{% block title %}智能分析系统{% endblock %}</title>
|
| 9 |
+
<!-- Bootstrap CSS -->
|
| 10 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 11 |
+
<!-- Font Awesome -->
|
| 12 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 13 |
+
<!-- ApexCharts -->
|
| 14 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.css" rel="stylesheet">
|
| 15 |
+
<!-- Custom CSS -->
|
| 16 |
+
<style>
|
| 17 |
+
body {
|
| 18 |
+
font-family: 'Helvetica Neue', Arial, sans-serif;
|
| 19 |
+
background-color: #f8f9fa;
|
| 20 |
+
}
|
| 21 |
+
.navbar-brand {
|
| 22 |
+
font-weight: bold;
|
| 23 |
+
}
|
| 24 |
+
.nav-item {
|
| 25 |
+
margin-left: 10px;
|
| 26 |
+
}
|
| 27 |
+
.sidebar {
|
| 28 |
+
background-color: #343a40;
|
| 29 |
+
color: white;
|
| 30 |
+
min-height: calc(100vh - 56px);
|
| 31 |
+
}
|
| 32 |
+
.sidebar .nav-link {
|
| 33 |
+
color: #ced4da;
|
| 34 |
+
padding: 0.75rem 1rem;
|
| 35 |
+
}
|
| 36 |
+
.sidebar .nav-link:hover {
|
| 37 |
+
color: #fff;
|
| 38 |
+
background-color: rgba(255, 255, 255, 0.1);
|
| 39 |
+
}
|
| 40 |
+
.sidebar .nav-link.active {
|
| 41 |
+
color: #fff;
|
| 42 |
+
background-color: rgba(255, 255, 255, 0.2);
|
| 43 |
+
}
|
| 44 |
+
.sidebar .nav-link i {
|
| 45 |
+
margin-right: 10px;
|
| 46 |
+
width: 20px;
|
| 47 |
+
text-align: center;
|
| 48 |
+
}
|
| 49 |
+
.main-content {
|
| 50 |
+
padding: 20px;
|
| 51 |
+
}
|
| 52 |
+
.card {
|
| 53 |
+
margin-bottom: 20px;
|
| 54 |
+
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
| 55 |
+
overflow: hidden; /* Prevent content from stretching container */
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.card-header {
|
| 59 |
+
padding: 0.5rem 1rem;
|
| 60 |
+
height: auto !important;
|
| 61 |
+
max-height: 50px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.form-control, .form-select, .input-group-text {
|
| 65 |
+
font-size: 0.875rem;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.input-group-sm .input-group-text {
|
| 69 |
+
padding: 0.25rem 0.5rem;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.card-body.py-2 {
|
| 73 |
+
padding-top: 0.5rem;
|
| 74 |
+
padding-bottom: 0.5rem;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
.card-body {
|
| 79 |
+
padding: 1.25rem;
|
| 80 |
+
overflow: hidden; /* Prevent content from stretching container */
|
| 81 |
+
}
|
| 82 |
+
.loading {
|
| 83 |
+
display: flex;
|
| 84 |
+
justify-content: center;
|
| 85 |
+
align-items: center;
|
| 86 |
+
height: 200px;
|
| 87 |
+
}
|
| 88 |
+
.spinner-border {
|
| 89 |
+
width: 3rem;
|
| 90 |
+
height: 3rem;
|
| 91 |
+
}
|
| 92 |
+
.badge-success {
|
| 93 |
+
background-color: #28a745;
|
| 94 |
+
}
|
| 95 |
+
.badge-danger {
|
| 96 |
+
background-color: #dc3545;
|
| 97 |
+
}
|
| 98 |
+
.badge-warning {
|
| 99 |
+
background-color: #ffc107;
|
| 100 |
+
color: #212529;
|
| 101 |
+
}
|
| 102 |
+
.score-pill {
|
| 103 |
+
font-size: 1.2rem;
|
| 104 |
+
padding: 0.5rem 1rem;
|
| 105 |
+
}
|
| 106 |
+
#loading-overlay {
|
| 107 |
+
position: fixed;
|
| 108 |
+
top: 0;
|
| 109 |
+
left: 0;
|
| 110 |
+
width: 100%;
|
| 111 |
+
height: 100%;
|
| 112 |
+
background-color: rgba(255, 255, 255, 0.8);
|
| 113 |
+
display: none;
|
| 114 |
+
justify-content: center;
|
| 115 |
+
align-items: center;
|
| 116 |
+
z-index: 9999;
|
| 117 |
+
}
|
| 118 |
+
.text-strong {
|
| 119 |
+
font-weight: bold;
|
| 120 |
+
}
|
| 121 |
+
.text-larger {
|
| 122 |
+
font-size: 1.1em;
|
| 123 |
+
}
|
| 124 |
+
.trend-up {
|
| 125 |
+
color: #28a745;
|
| 126 |
+
}
|
| 127 |
+
.trend-down {
|
| 128 |
+
color: #dc3545;
|
| 129 |
+
}
|
| 130 |
+
.analysis-section {
|
| 131 |
+
margin-bottom: 1.5rem;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* Fix for chart container heights */
|
| 135 |
+
#price-chart {
|
| 136 |
+
height: 400px !important;
|
| 137 |
+
max-height: 400px;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* Fix for indicators chart container */
|
| 141 |
+
#indicators-chart {
|
| 142 |
+
height: 350px !important;
|
| 143 |
+
max-height: 350px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* Fix chart containers */
|
| 147 |
+
.apexcharts-canvas {
|
| 148 |
+
overflow: visible !important;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* Fix for radar chart */
|
| 152 |
+
#radar-chart {
|
| 153 |
+
height: 200px !important;
|
| 154 |
+
max-height: 200px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* Fix for score chart */
|
| 158 |
+
#score-chart {
|
| 159 |
+
height: 200px !important;
|
| 160 |
+
max-height: 200px;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/* Fix header alignment */
|
| 164 |
+
.card-header h5 {
|
| 165 |
+
margin-bottom: 0;
|
| 166 |
+
display: flex;
|
| 167 |
+
align-items: center;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.apexcharts-tooltip {
|
| 171 |
+
background: #fff !important;
|
| 172 |
+
border: 1px solid #e3e3e3 !important;
|
| 173 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
|
| 174 |
+
border-radius: 4px !important;
|
| 175 |
+
padding: 8px !important;
|
| 176 |
+
font-size: 13px !important;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.apexcharts-tooltip-title {
|
| 180 |
+
background: #f8f9fa !important;
|
| 181 |
+
border-bottom: 1px solid #e3e3e3 !important;
|
| 182 |
+
padding: 6px 8px !important;
|
| 183 |
+
margin-bottom: 4px !important;
|
| 184 |
+
font-weight: 600 !important;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.apexcharts-tooltip-y-group {
|
| 188 |
+
padding: 3px 0 !important;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.apexcharts-tooltip-candlestick {
|
| 192 |
+
padding: 5px 8px !important;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.apexcharts-tooltip-candlestick div {
|
| 196 |
+
margin: 3px 0 !important;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.apexcharts-tooltip-candlestick span {
|
| 200 |
+
font-weight: 600 !important;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.apexcharts-crosshairs {
|
| 204 |
+
stroke-width: 1px !important;
|
| 205 |
+
stroke: #90A4AE !important;
|
| 206 |
+
stroke-dasharray: 0 !important;
|
| 207 |
+
opacity: 0.8 !important;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.apexcharts-tooltip-marker {
|
| 211 |
+
width: 10px !important;
|
| 212 |
+
height: 10px !important;
|
| 213 |
+
display: inline-block !important;
|
| 214 |
+
margin-right: 5px !important;
|
| 215 |
+
border-radius: 50% !important;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.apexcharts-tooltip-series-group {
|
| 219 |
+
padding: 4px 8px !important;
|
| 220 |
+
border-bottom: 1px solid #eee !important;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.apexcharts-tooltip-series-group:last-child {
|
| 224 |
+
border-bottom: none !important;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.apexcharts-tooltip-text-y-value {
|
| 228 |
+
font-weight: 600 !important;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.apexcharts-xaxistooltip {
|
| 232 |
+
background: #fff !important;
|
| 233 |
+
border: 1px solid #e3e3e3 !important;
|
| 234 |
+
border-radius: 2px !important;
|
| 235 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
|
| 236 |
+
padding: 4px 8px !important;
|
| 237 |
+
font-size: 12px !important;
|
| 238 |
+
color: #333 !important;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.apexcharts-yaxistooltip {
|
| 242 |
+
background: #fff !important;
|
| 243 |
+
border: 1px solid #e3e3e3 !important;
|
| 244 |
+
border-radius: 2px !important;
|
| 245 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
|
| 246 |
+
padding: 4px 8px !important;
|
| 247 |
+
font-size: 12px !important;
|
| 248 |
+
color: #333 !important;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/* AI分析样式 */
|
| 252 |
+
.analysis-para {
|
| 253 |
+
line-height: 1.8;
|
| 254 |
+
margin-bottom: 1.2rem;
|
| 255 |
+
color: #333;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.keyword {
|
| 259 |
+
color: #2c7be5;
|
| 260 |
+
font-weight: 600;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.term {
|
| 264 |
+
color: #d6336c;
|
| 265 |
+
font-weight: 500;
|
| 266 |
+
padding: 0 2px;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.price {
|
| 270 |
+
color: #00a47c;
|
| 271 |
+
font-family: 'Roboto Mono', monospace;
|
| 272 |
+
background: #f3faf8;
|
| 273 |
+
padding: 2px 4px;
|
| 274 |
+
border-radius: 3px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.date {
|
| 278 |
+
color: #6c757d;
|
| 279 |
+
font-family: 'Roboto Mono', monospace;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
strong.keyword {
|
| 283 |
+
border-bottom: 2px solid #2c7be5;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.table-info {
|
| 287 |
+
position: relative;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.table-info:after {
|
| 291 |
+
content: '';
|
| 292 |
+
position: absolute;
|
| 293 |
+
top: 0;
|
| 294 |
+
left: 0;
|
| 295 |
+
right: 0;
|
| 296 |
+
bottom: 0;
|
| 297 |
+
background: rgba(0, 123, 255, 0.1);
|
| 298 |
+
animation: pulse 1.5s infinite;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
@keyframes pulse {
|
| 302 |
+
0% { opacity: 0.5; }
|
| 303 |
+
50% { opacity: 0.3; }
|
| 304 |
+
100% { opacity: 0.5; }
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
</style>
|
| 308 |
+
{% block head %}{% endblock %}
|
| 309 |
+
</head>
|
| 310 |
+
<body>
|
| 311 |
+
<!-- Loading Overlay -->
|
| 312 |
+
<div id="loading-overlay">
|
| 313 |
+
<div class="spinner-border text-primary" role="status">
|
| 314 |
+
<span class="visually-hidden">Loading...</span>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
<!-- Top Navigation -->
|
| 319 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
| 320 |
+
<div class="container-fluid">
|
| 321 |
+
<a class="navbar-brand" href="/">智能分析系统</a>
|
| 322 |
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 323 |
+
<span class="navbar-toggler-icon"></span>
|
| 324 |
+
</button>
|
| 325 |
+
<!-- 在layout.html的导航栏部分修改 -->
|
| 326 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 327 |
+
<ul class="navbar-nav me-auto">
|
| 328 |
+
<li class="nav-item">
|
| 329 |
+
<a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/"><i class="fas fa-home"></i> 主页</a>
|
| 330 |
+
</li>
|
| 331 |
+
<li class="nav-item">
|
| 332 |
+
<a class="nav-link {% if request.path == '/dashboard' %}active{% endif %}" href="/dashboard"><i class="fas fa-chart-line"></i> 智能仪表盘</a>
|
| 333 |
+
</li>
|
| 334 |
+
<!-- 新增菜单项 - 基本面分析 -->
|
| 335 |
+
<li class="nav-item">
|
| 336 |
+
<a class="nav-link {% if request.path.startswith('/fundamental') %}active{% endif %}" href="/fundamental"><i class="fas fa-file-invoice-dollar"></i> 基本面分析</a>
|
| 337 |
+
</li>
|
| 338 |
+
<!-- 新增菜单项 - 资金流向 -->
|
| 339 |
+
<li class="nav-item">
|
| 340 |
+
<a class="nav-link {% if request.path.startswith('/capital_flow') %}active{% endif %}" href="/capital_flow"><i class="fas fa-money-bill-wave"></i> 资金流向</a>
|
| 341 |
+
</li>
|
| 342 |
+
<!-- 新增菜单项 - 情景预测 -->
|
| 343 |
+
<li class="nav-item">
|
| 344 |
+
<a class="nav-link {% if request.path.startswith('/scenario') %}active{% endif %}" href="/scenario_predict"><i class="fas fa-lightbulb"></i> 情景预测</a>
|
| 345 |
+
</li>
|
| 346 |
+
<li class="nav-item">
|
| 347 |
+
<a class="nav-link {% if request.path == '/market_scan' %}active{% endif %}" href="/market_scan"><i class="fas fa-search"></i> 市场扫描</a>
|
| 348 |
+
</li>
|
| 349 |
+
<li class="nav-item">
|
| 350 |
+
<a class="nav-link {% if request.path == '/portfolio' %}active{% endif %}" href="/portfolio"><i class="fas fa-briefcase"></i> 投资组合</a>
|
| 351 |
+
</li>
|
| 352 |
+
<!-- 新增菜单项 - 风险监控 -->
|
| 353 |
+
<li class="nav-item">
|
| 354 |
+
<a class="nav-link {% if request.path.startswith('/risk') %}active{% endif %}" href="/risk_monitor"><i class="fas fa-exclamation-triangle"></i> 风险监控</a>
|
| 355 |
+
</li>
|
| 356 |
+
<!-- 新增菜单项 - 智能问答 -->
|
| 357 |
+
<li class="nav-item">
|
| 358 |
+
<a class="nav-link {% if request.path == '/qa' %}active{% endif %}" href="/qa"><i class="fas fa-question-circle"></i> 智能问答</a>
|
| 359 |
+
</li>
|
| 360 |
+
</ul>
|
| 361 |
+
<div class="d-flex">
|
| 362 |
+
<div class="input-group">
|
| 363 |
+
<input type="text" id="search-stock" class="form-control" placeholder="搜索股票代码/名称" aria-label="搜索股票">
|
| 364 |
+
<button class="btn btn-light" type="button" id="search-button">
|
| 365 |
+
<i class="fas fa-search"></i>
|
| 366 |
+
</button>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
</nav>
|
| 372 |
+
|
| 373 |
+
<div class="container-fluid">
|
| 374 |
+
<div class="row">
|
| 375 |
+
{% block sidebar %}{% endblock %}
|
| 376 |
+
|
| 377 |
+
<main class="{% if self.sidebar()|trim %}col-md-9 ms-sm-auto col-lg-10 px-md-4{% else %}col-12{% endif %} main-content">
|
| 378 |
+
{% block content %}{% endblock %}
|
| 379 |
+
</main>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
<!-- Bootstrap JS with Popper -->
|
| 384 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 385 |
+
<!-- jQuery -->
|
| 386 |
+
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
| 387 |
+
<!-- ApexCharts -->
|
| 388 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.js"></script>
|
| 389 |
+
<!-- Common JS -->
|
| 390 |
+
<script>
|
| 391 |
+
// 显示加载中覆盖层
|
| 392 |
+
function showLoading() {
|
| 393 |
+
$('#loading-overlay').css('display', 'flex');
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
// 隐藏加载中覆盖层
|
| 397 |
+
function hideLoading() {
|
| 398 |
+
$('#loading-overlay').css('display', 'none');
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// 显示错误提示
|
| 402 |
+
function showError(message) {
|
| 403 |
+
const alertHtml = `
|
| 404 |
+
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
| 405 |
+
${message}
|
| 406 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 407 |
+
</div>
|
| 408 |
+
`;
|
| 409 |
+
$('#alerts-container').html(alertHtml);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// 显示信息提示
|
| 413 |
+
function showInfo(message) {
|
| 414 |
+
const alertHtml = `
|
| 415 |
+
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
| 416 |
+
<i class="fas fa-info-circle me-2"></i>${message}
|
| 417 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 418 |
+
</div>
|
| 419 |
+
`;
|
| 420 |
+
$('#alerts-container').html(alertHtml);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
// 显示成功提示
|
| 424 |
+
function showSuccess(message) {
|
| 425 |
+
const alertHtml = `
|
| 426 |
+
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
| 427 |
+
${message}
|
| 428 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 429 |
+
</div>
|
| 430 |
+
`;
|
| 431 |
+
$('#alerts-container').html(alertHtml);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// 搜索股票并跳转到详情页
|
| 435 |
+
$('#search-button').click(function() {
|
| 436 |
+
const stockCode = $('#search-stock').val().trim();
|
| 437 |
+
if (stockCode) {
|
| 438 |
+
window.location.href = `/stock_detail/${stockCode}`;
|
| 439 |
+
}
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
// 回车键搜索
|
| 443 |
+
$('#search-stock').keypress(function(e) {
|
| 444 |
+
if (e.which === 13) {
|
| 445 |
+
$('#search-button').click();
|
| 446 |
+
}
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
// 格式化数字 - 增强版
|
| 450 |
+
function formatNumber(num, digits = 2) {
|
| 451 |
+
if (num === null || num === undefined) return '-';
|
| 452 |
+
return parseFloat(num).toFixed(digits);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
// 格式化技术指标 - 新增函数
|
| 456 |
+
function formatIndicator(value, indicatorType) {
|
| 457 |
+
if (value === null || value === undefined) return '-';
|
| 458 |
+
|
| 459 |
+
// 根据指标类型使用不同的小数位数
|
| 460 |
+
if (indicatorType === 'MACD' || indicatorType === 'Signal' || indicatorType === 'Histogram') {
|
| 461 |
+
return parseFloat(value).toFixed(3); // MACD相关指标使用3位小数
|
| 462 |
+
} else if (indicatorType === 'RSI') {
|
| 463 |
+
return parseFloat(value).toFixed(2); // RSI使用2位小数
|
| 464 |
+
} else {
|
| 465 |
+
return parseFloat(value).toFixed(2); // 默认使用2位小数
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
// 格式化百分比
|
| 470 |
+
function formatPercent(num, digits = 2) {
|
| 471 |
+
if (num === null || num === undefined) return '-';
|
| 472 |
+
return parseFloat(num).toFixed(digits) + '%';
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// 根据评分获取颜色类
|
| 476 |
+
function getScoreColorClass(score) {
|
| 477 |
+
if (score >= 80) return 'bg-success';
|
| 478 |
+
if (score >= 60) return 'bg-primary';
|
| 479 |
+
if (score >= 40) return 'bg-warning';
|
| 480 |
+
return 'bg-danger';
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// 根据趋势获取颜色类
|
| 484 |
+
function getTrendColorClass(trend) {
|
| 485 |
+
return trend === 'UP' ? 'trend-up' : 'trend-down';
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
// 根据趋势获取图标
|
| 489 |
+
function getTrendIcon(trend) {
|
| 490 |
+
return trend === 'UP' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| 491 |
+
}
|
| 492 |
+
</script>
|
| 493 |
+
{% block scripts %}{% endblock %}
|
| 494 |
+
</body>
|
| 495 |
+
</html>
|
templates/market_scan.html
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}市场扫描 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-4">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-4">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header d-flex justify-content-between">
|
| 13 |
+
<h5 class="mb-0">市场扫描</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body">
|
| 16 |
+
<form id="scan-form" class="row g-3">
|
| 17 |
+
<div class="col-md-3">
|
| 18 |
+
<div class="input-group">
|
| 19 |
+
<span class="input-group-text">选择指数</span>
|
| 20 |
+
<select class="form-select" id="index-selector">
|
| 21 |
+
<option value="">-- 选择指数 --</option>
|
| 22 |
+
<option value="000300">沪深300</option>
|
| 23 |
+
<option value="000905">中证500</option>
|
| 24 |
+
<option value="000852">中证1000</option>
|
| 25 |
+
<option value="000001">上证指数</option>
|
| 26 |
+
</select>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="col-md-3">
|
| 30 |
+
<div class="input-group">
|
| 31 |
+
<span class="input-group-text">选择行业</span>
|
| 32 |
+
<select class="form-select" id="industry-selector">
|
| 33 |
+
<option value="">-- 选择行业 --</option>
|
| 34 |
+
<option value="保险">保险</option>
|
| 35 |
+
<option value="食品饮料">食品饮料</option>
|
| 36 |
+
<option value="多元金融">多元金融</option>
|
| 37 |
+
<option value="游戏">游戏</option>
|
| 38 |
+
<option value="酿酒行业">酿酒行业</option>
|
| 39 |
+
<option value="商业百货">商业百货</option>
|
| 40 |
+
<option value="证券">证券</option>
|
| 41 |
+
<option value="船舶制造">船舶制造</option>
|
| 42 |
+
<option value="家用轻工">家用轻工</option>
|
| 43 |
+
<option value="旅游酒店">旅游酒店</option>
|
| 44 |
+
<option value="美容护理">美容护理</option>
|
| 45 |
+
<option value="医疗服务">医疗服务</option>
|
| 46 |
+
<option value="软件开发">软件开发</option>
|
| 47 |
+
<option value="化学制药">化学制药</option>
|
| 48 |
+
<option value="医疗器械">医疗器械</option>
|
| 49 |
+
<option value="家电行业">家电行业</option>
|
| 50 |
+
<option value="汽车服务">汽车服务</option>
|
| 51 |
+
<option value="造纸印刷">造纸印刷</option>
|
| 52 |
+
<option value="纺织服装">纺织服装</option>
|
| 53 |
+
<option value="光伏设备">光伏设备</option>
|
| 54 |
+
<option value="房地产服务">房地产服务</option>
|
| 55 |
+
<option value="文化传媒">文化传媒</option>
|
| 56 |
+
<option value="医药商业">医药商业</option>
|
| 57 |
+
<option value="中药">中药</option>
|
| 58 |
+
<option value="专业服务">专业服务</option>
|
| 59 |
+
<option value="生物制品">生物制品</option>
|
| 60 |
+
<option value="仪器仪表">仪器仪表</option>
|
| 61 |
+
<option value="房地产开发">房地产开发</option>
|
| 62 |
+
<option value="教育">教育</option>
|
| 63 |
+
<option value="半导体">半导体</option>
|
| 64 |
+
<option value="玻璃玻纤">玻璃玻纤</option>
|
| 65 |
+
<option value="汽车整车">汽车整车</option>
|
| 66 |
+
<option value="消费电子">消费电子</option>
|
| 67 |
+
<option value="贸易行业">贸易行业</option>
|
| 68 |
+
<option value="包装材料">包装材料</option>
|
| 69 |
+
<option value="汽车零部件">汽车零部件</option>
|
| 70 |
+
<option value="电子化学品">电子化学品</option>
|
| 71 |
+
<option value="电子元件">电子元件</option>
|
| 72 |
+
<option value="装修建材">装修建材</option>
|
| 73 |
+
<option value="交运设备">交运设备</option>
|
| 74 |
+
<option value="农牧饲渔">农牧饲渔</option>
|
| 75 |
+
<option value="塑料制品">塑料制品</option>
|
| 76 |
+
<option value="珠宝首饰">珠宝首饰</option>
|
| 77 |
+
<option value="贵金属">贵金属</option>
|
| 78 |
+
<option value="非金属材料">非金属材料</option>
|
| 79 |
+
<option value="装修装饰">装修装饰</option>
|
| 80 |
+
<option value="风电设备">风电设备</option>
|
| 81 |
+
<option value="工程咨询服务">工程咨询服务</option>
|
| 82 |
+
<option value="专用设备">专用设备</option>
|
| 83 |
+
<option value="光学光电子">光学光电子</option>
|
| 84 |
+
<option value="航空机场">航空机场</option>
|
| 85 |
+
<option value="小金属">小金属</option>
|
| 86 |
+
<option value="物流行业">物流行业</option>
|
| 87 |
+
<option value="通用设备">通用设备</option>
|
| 88 |
+
<option value="计算机设备">计算机设备</option>
|
| 89 |
+
<option value="环保行业">环保行业</option>
|
| 90 |
+
<option value="航运港口">航运港口</option>
|
| 91 |
+
<option value="通信设备">通信设备</option>
|
| 92 |
+
<option value="水泥建材">水泥建材</option>
|
| 93 |
+
<option value="电池">电池</option>
|
| 94 |
+
<option value="化肥行业">化肥行业</option>
|
| 95 |
+
<option value="互联网服务">互联网服务</option>
|
| 96 |
+
<option value="工程建设">工程建设</option>
|
| 97 |
+
<option value="橡胶制品">橡胶制品</option>
|
| 98 |
+
<option value="化学原料">化学原料</option>
|
| 99 |
+
<option value="化纤行业">化纤行业</option>
|
| 100 |
+
<option value="农药兽药">农药兽药</option>
|
| 101 |
+
<option value="化学制品">化学制品</option>
|
| 102 |
+
<option value="能源金属">能源金属</option>
|
| 103 |
+
<option value="有色金属">有色金属</option>
|
| 104 |
+
<option value="采掘行业">采掘行业</option>
|
| 105 |
+
<option value="燃气">燃气</option>
|
| 106 |
+
<option value="综合行业">综合行业</option>
|
| 107 |
+
<option value="工程机械">工程机械</option>
|
| 108 |
+
<option value="银行">银行</option>
|
| 109 |
+
<option value="铁路公路">铁路公路</option>
|
| 110 |
+
<option value="石油行业">石油行业</option>
|
| 111 |
+
<option value="公用事业">公用事业</option>
|
| 112 |
+
<option value="电机">电机</option>
|
| 113 |
+
<option value="通信服务">通信服务</option>
|
| 114 |
+
<option value="钢铁行业">钢铁行业</option>
|
| 115 |
+
<option value="电力行业">电力行业</option>
|
| 116 |
+
<option value="电网设备">电网设备</option>
|
| 117 |
+
<option value="煤炭行业">煤炭行业</option>
|
| 118 |
+
<option value="电源设备">电源设备</option>
|
| 119 |
+
<option value="航天航空">航天航空</option>
|
| 120 |
+
</select>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="col-md-3">
|
| 124 |
+
<div class="input-group">
|
| 125 |
+
<span class="input-group-text">自定义股票</span>
|
| 126 |
+
<input type="text" class="form-control" id="custom-stocks" placeholder="多个股票代码用逗号分隔">
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="col-md-3">
|
| 130 |
+
<div class="input-group">
|
| 131 |
+
<span class="input-group-text">最低分数</span>
|
| 132 |
+
<input type="number" class="form-control" id="min-score" value="60" min="0" max="100">
|
| 133 |
+
<button type="submit" class="btn btn-primary">
|
| 134 |
+
<i class="fas fa-search"></i> 扫描
|
| 135 |
+
</button>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</form>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div class="row mb-4">
|
| 145 |
+
<div class="col-12">
|
| 146 |
+
<div class="card">
|
| 147 |
+
<div class="card-header d-flex justify-content-between">
|
| 148 |
+
<h5 class="mb-0">扫描结果</h5>
|
| 149 |
+
<div>
|
| 150 |
+
<span class="badge bg-primary ms-2" id="result-count">0</span>
|
| 151 |
+
<button class="btn btn-sm btn-outline-primary ms-2" id="export-btn" style="display: none;">
|
| 152 |
+
<i class="fas fa-download"></i> 导出结果
|
| 153 |
+
</button>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="card-body">
|
| 157 |
+
<div id="scan-loading" class="text-center py-5" style="display: none;">
|
| 158 |
+
<div class="spinner-border text-primary" role="status">
|
| 159 |
+
<span class="visually-hidden">Loading...</span>
|
| 160 |
+
</div>
|
| 161 |
+
<p class="mt-2" id="scan-message">正在扫描市场,请稍候...</p>
|
| 162 |
+
<div class="progress mt-3" style="height: 5px;">
|
| 163 |
+
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
|
| 164 |
+
</div>
|
| 165 |
+
<button id="cancel-scan-btn" class="btn btn-outline-secondary mt-3">
|
| 166 |
+
<i class="fas fa-times"></i> 取消扫描
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<!-- 添加错误重试区域 -->
|
| 171 |
+
<div id="scan-error-retry" class="text-center mt-3" style="display: none;">
|
| 172 |
+
<button id="scan-retry-button" class="btn btn-primary mt-2">
|
| 173 |
+
<i class="fas fa-sync-alt"></i> 重试扫描
|
| 174 |
+
</button>
|
| 175 |
+
<p class="text-muted small mt-2">
|
| 176 |
+
已超负载
|
| 177 |
+
</p>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<div id="scan-results">
|
| 181 |
+
<table class="table table-hover">
|
| 182 |
+
<thead>
|
| 183 |
+
<tr>
|
| 184 |
+
<th>代码</th>
|
| 185 |
+
<th>名称</th>
|
| 186 |
+
<th>行业</th>
|
| 187 |
+
<th>得分</th>
|
| 188 |
+
<th>价格</th>
|
| 189 |
+
<th>涨跌幅</th>
|
| 190 |
+
<th>RSI</th>
|
| 191 |
+
<th>MA趋势</th>
|
| 192 |
+
<th>成交量</th>
|
| 193 |
+
<th>建议</th>
|
| 194 |
+
<th>操作</th>
|
| 195 |
+
</tr>
|
| 196 |
+
</thead>
|
| 197 |
+
<tbody id="results-table">
|
| 198 |
+
<tr>
|
| 199 |
+
<td colspan="11" class="text-center">暂无数据,请开始扫描</td>
|
| 200 |
+
</tr>
|
| 201 |
+
</tbody>
|
| 202 |
+
</table>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
{% endblock %}
|
| 210 |
+
|
| 211 |
+
{% block scripts %}
|
| 212 |
+
<script>
|
| 213 |
+
$(document).ready(function() {
|
| 214 |
+
// 表单提交
|
| 215 |
+
$('#scan-form').submit(function(e) {
|
| 216 |
+
e.preventDefault();
|
| 217 |
+
|
| 218 |
+
// 获取股票列表
|
| 219 |
+
let stockList = [];
|
| 220 |
+
|
| 221 |
+
// 获取指数股票
|
| 222 |
+
const indexCode = $('#index-selector').val();
|
| 223 |
+
if (indexCode) {
|
| 224 |
+
fetchIndexStocks(indexCode);
|
| 225 |
+
return;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 获取行业股票
|
| 229 |
+
const industry = $('#industry-selector').val();
|
| 230 |
+
if (industry) {
|
| 231 |
+
fetchIndustryStocks(industry);
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// 获取自定义股票
|
| 236 |
+
const customStocks = $('#custom-stocks').val().trim();
|
| 237 |
+
if (customStocks) {
|
| 238 |
+
stockList = customStocks.split(',').map(s => s.trim());
|
| 239 |
+
scanMarket(stockList);
|
| 240 |
+
} else {
|
| 241 |
+
showError('请至少选择一种方式获取股票列表');
|
| 242 |
+
}
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// 指数选择变化
|
| 246 |
+
$('#index-selector').change(function() {
|
| 247 |
+
if ($(this).val()) {
|
| 248 |
+
$('#industry-selector').val('');
|
| 249 |
+
}
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
// 行业选择变化
|
| 253 |
+
$('#industry-selector').change(function() {
|
| 254 |
+
if ($(this).val()) {
|
| 255 |
+
$('#index-selector').val('');
|
| 256 |
+
}
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
// 导出结果
|
| 260 |
+
$('#export-btn').click(function() {
|
| 261 |
+
exportToCSV();
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
// 获取指数成分股
|
| 265 |
+
function fetchIndexStocks(indexCode) {
|
| 266 |
+
$('#scan-loading').show();
|
| 267 |
+
$('#scan-results').hide();
|
| 268 |
+
|
| 269 |
+
$.ajax({
|
| 270 |
+
url: `/api/index_stocks?index_code=${indexCode}`,
|
| 271 |
+
type: 'GET',
|
| 272 |
+
dataType: 'json',
|
| 273 |
+
success: function(response) {
|
| 274 |
+
const stockList = response.stock_list;
|
| 275 |
+
if (stockList && stockList.length > 0) {
|
| 276 |
+
// 保存最近的扫描列表用于重试
|
| 277 |
+
window.lastScanList = stockList;
|
| 278 |
+
|
| 279 |
+
scanMarket(stockList);
|
| 280 |
+
} else {
|
| 281 |
+
$('#scan-loading').hide();
|
| 282 |
+
$('#scan-results').show();
|
| 283 |
+
showError('获取指数成分股失败,或成分股列表为空');
|
| 284 |
+
}
|
| 285 |
+
},
|
| 286 |
+
error: function(error) {
|
| 287 |
+
$('#scan-loading').hide();
|
| 288 |
+
$('#scan-results').show();
|
| 289 |
+
showError('获取指数成分股失败: ' + (error.responseJSON ? error.responseJSON.error : error.statusText));
|
| 290 |
+
}
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// 获取行业成分股
|
| 295 |
+
function fetchIndustryStocks(industry) {
|
| 296 |
+
$('#scan-loading').show();
|
| 297 |
+
$('#scan-results').hide();
|
| 298 |
+
|
| 299 |
+
$.ajax({
|
| 300 |
+
url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
|
| 301 |
+
type: 'GET',
|
| 302 |
+
dataType: 'json',
|
| 303 |
+
success: function(response) {
|
| 304 |
+
const stockList = response.stock_list;
|
| 305 |
+
if (stockList && stockList.length > 0) {
|
| 306 |
+
// 保存最近的扫描列表用于重试
|
| 307 |
+
window.lastScanList = stockList;
|
| 308 |
+
|
| 309 |
+
scanMarket(stockList);
|
| 310 |
+
} else {
|
| 311 |
+
$('#scan-loading').hide();
|
| 312 |
+
$('#scan-results').show();
|
| 313 |
+
showError('获取行业成分股失败,或成分股列表为空');
|
| 314 |
+
}
|
| 315 |
+
},
|
| 316 |
+
error: function(error) {
|
| 317 |
+
$('#scan-loading').hide();
|
| 318 |
+
$('#scan-results').show();
|
| 319 |
+
showError('获取行业成分股失败: ' + (error.responseJSON ? error.responseJSON.error : error.statusText));
|
| 320 |
+
}
|
| 321 |
+
});
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 扫描市场
|
| 325 |
+
function scanMarket(stockList) {
|
| 326 |
+
$('#scan-loading').show();
|
| 327 |
+
$('#scan-results').hide();
|
| 328 |
+
$('#scan-error-retry').hide();
|
| 329 |
+
|
| 330 |
+
// 添加处理时间计数器
|
| 331 |
+
let processingTime = 0;
|
| 332 |
+
let stockCount = stockList.length;
|
| 333 |
+
|
| 334 |
+
// 保存上次扫描列表
|
| 335 |
+
window.lastScanList = stockList;
|
| 336 |
+
|
| 337 |
+
// 更新扫描提示消息
|
| 338 |
+
$('#scan-message').html(`正在准备扫描${stockCount}只股票,请稍候...`);
|
| 339 |
+
|
| 340 |
+
const minScore = parseInt($('#min-score').val() || 60);
|
| 341 |
+
|
| 342 |
+
// 第一步:启动扫描任务
|
| 343 |
+
$.ajax({
|
| 344 |
+
url: '/api/start_market_scan',
|
| 345 |
+
type: 'POST',
|
| 346 |
+
contentType: 'application/json',
|
| 347 |
+
data: JSON.stringify({
|
| 348 |
+
stock_list: stockList,
|
| 349 |
+
min_score: minScore,
|
| 350 |
+
market_type: 'A'
|
| 351 |
+
}),
|
| 352 |
+
success: function(response) {
|
| 353 |
+
const taskId = response.task_id;
|
| 354 |
+
|
| 355 |
+
if (!taskId) {
|
| 356 |
+
showError('启动扫描任务失败:未获取到任务ID');
|
| 357 |
+
$('#scan-loading').hide();
|
| 358 |
+
$('#scan-results').show();
|
| 359 |
+
$('#scan-error-retry').show();
|
| 360 |
+
return;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// 启动轮询任务状态
|
| 364 |
+
pollScanStatus(taskId, processingTime);
|
| 365 |
+
},
|
| 366 |
+
error: function(xhr, status, error) {
|
| 367 |
+
$('#scan-loading').hide();
|
| 368 |
+
$('#scan-results').show();
|
| 369 |
+
|
| 370 |
+
let errorMsg = '启动扫描任务失败';
|
| 371 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 372 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 373 |
+
} else if (error) {
|
| 374 |
+
errorMsg += ': ' + error;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
showError(errorMsg);
|
| 378 |
+
$('#scan-error-retry').show();
|
| 379 |
+
}
|
| 380 |
+
});
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
// 轮询扫描任务状态
|
| 384 |
+
function pollScanStatus(taskId, startTime) {
|
| 385 |
+
let elapsedTime = startTime || 0;
|
| 386 |
+
let pollInterval;
|
| 387 |
+
|
| 388 |
+
// 立即执行一次,然后设置定时器
|
| 389 |
+
checkStatus();
|
| 390 |
+
|
| 391 |
+
function checkStatus() {
|
| 392 |
+
$.ajax({
|
| 393 |
+
url: `/api/scan_status/${taskId}`,
|
| 394 |
+
type: 'GET',
|
| 395 |
+
success: function(response) {
|
| 396 |
+
// 更新计时和进度
|
| 397 |
+
elapsedTime++;
|
| 398 |
+
const progress = response.progress || 0;
|
| 399 |
+
|
| 400 |
+
// 更新进度消息
|
| 401 |
+
$('#scan-message').html(`正在扫描市场...<br>
|
| 402 |
+
进度: ${progress}% 完成<br>
|
| 403 |
+
已处理 ${Math.round(response.total * progress / 100)} / ${response.total} 只股票<br>
|
| 404 |
+
耗时: ${elapsedTime}秒`);
|
| 405 |
+
|
| 406 |
+
// 检查任务状态
|
| 407 |
+
if (response.status === 'completed') {
|
| 408 |
+
// 扫描完成,停止轮询
|
| 409 |
+
clearInterval(pollInterval);
|
| 410 |
+
|
| 411 |
+
// 显示结果
|
| 412 |
+
renderResults(response.result || []);
|
| 413 |
+
$('#scan-loading').hide();
|
| 414 |
+
$('#scan-results').show();
|
| 415 |
+
|
| 416 |
+
// 如果结果为空,显示提示
|
| 417 |
+
if (!response.result || response.result.length === 0) {
|
| 418 |
+
$('#results-table').html('<tr><td colspan="11" class="text-center">未找到符合条件的股票</td></tr>');
|
| 419 |
+
$('#result-count').text('0');
|
| 420 |
+
$('#export-btn').hide();
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
} else if (response.status === 'failed') {
|
| 424 |
+
// 扫描失败,停止轮询
|
| 425 |
+
clearInterval(pollInterval);
|
| 426 |
+
|
| 427 |
+
$('#scan-loading').hide();
|
| 428 |
+
$('#scan-results').show();
|
| 429 |
+
|
| 430 |
+
showError('扫描任务失败: ' + (response.error || '未知错误'));
|
| 431 |
+
$('#scan-error-retry').show();
|
| 432 |
+
|
| 433 |
+
} else {
|
| 434 |
+
// 任务仍在进行中,继续轮询
|
| 435 |
+
// 轮询间隔根据进度动态调整
|
| 436 |
+
if (!pollInterval) {
|
| 437 |
+
pollInterval = setInterval(checkStatus, 2000);
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
},
|
| 441 |
+
error: function(xhr, status, error) {
|
| 442 |
+
|
| 443 |
+
// 尝试继续轮询
|
| 444 |
+
if (!pollInterval) {
|
| 445 |
+
pollInterval = setInterval(checkStatus, 3000);
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// 更新进度消息
|
| 449 |
+
$('#scan-message').html(`正在扫描市场...<br>
|
| 450 |
+
无法获取最新进度<br>
|
| 451 |
+
耗时: ${elapsedTime}秒`);
|
| 452 |
+
}
|
| 453 |
+
});
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// 取消扫描任务
|
| 458 |
+
function cancelScan(taskId) {
|
| 459 |
+
$.ajax({
|
| 460 |
+
url: `/api/cancel_scan/${taskId}`,
|
| 461 |
+
type: 'POST',
|
| 462 |
+
success: function(response) {
|
| 463 |
+
$('#scan-loading').hide();
|
| 464 |
+
$('#scan-results').show();
|
| 465 |
+
showError('扫描任务已取消');
|
| 466 |
+
$('#scan-error-retry').show();
|
| 467 |
+
},
|
| 468 |
+
error: function(xhr, status, error) {
|
| 469 |
+
console.error('取消扫描任务失败:', error);
|
| 470 |
+
}
|
| 471 |
+
});
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
// 渲染扫描结果
|
| 475 |
+
function renderResults(results) {
|
| 476 |
+
if (!results || results.length === 0) {
|
| 477 |
+
$('#results-table').html('<tr><td colspan="11" class="text-center">未找到符合条件的股票</td></tr>');
|
| 478 |
+
$('#result-count').text('0');
|
| 479 |
+
$('#export-btn').hide();
|
| 480 |
+
return;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
let html = '';
|
| 484 |
+
results.forEach(result => {
|
| 485 |
+
// 获取股票评分的颜色类
|
| 486 |
+
const scoreClass = getScoreColorClass(result.score);
|
| 487 |
+
|
| 488 |
+
// 获取MA趋势的类和图标
|
| 489 |
+
const maTrendClass = getTrendColorClass(result.ma_trend);
|
| 490 |
+
const maTrendIcon = getTrendIcon(result.ma_trend);
|
| 491 |
+
|
| 492 |
+
// 获取价格变动的类和图标
|
| 493 |
+
const priceChangeClass = result.price_change >= 0 ? 'trend-up' : 'trend-down';
|
| 494 |
+
const priceChangeIcon = result.price_change >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 495 |
+
|
| 496 |
+
html += `
|
| 497 |
+
<tr>
|
| 498 |
+
<td>${result.stock_code}</td>
|
| 499 |
+
<td>${result.stock_name || '未知'}</td>
|
| 500 |
+
<td>${result.industry || '-'}</td>
|
| 501 |
+
<td><span class="badge ${scoreClass}">${result.score}</span></td>
|
| 502 |
+
<td>${formatNumber(result.price)}</td>
|
| 503 |
+
<td class="${priceChangeClass}">${priceChangeIcon} ${formatPercent(result.price_change)}</td>
|
| 504 |
+
<td>${formatNumber(result.rsi)}</td>
|
| 505 |
+
<td class="${maTrendClass}">${maTrendIcon} ${result.ma_trend}</td>
|
| 506 |
+
<td>${result.volume_status}</td>
|
| 507 |
+
<td>${result.recommendation}</td>
|
| 508 |
+
<td>
|
| 509 |
+
<a href="/stock_detail/${result.stock_code}" class="btn btn-sm btn-primary">
|
| 510 |
+
<i class="fas fa-chart-line"></i> 详情
|
| 511 |
+
</a>
|
| 512 |
+
</td>
|
| 513 |
+
</tr>
|
| 514 |
+
`;
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
+
$('#results-table').html(html);
|
| 518 |
+
$('#result-count').text(results.length);
|
| 519 |
+
$('#export-btn').show();
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
// 导出到CSV
|
| 523 |
+
function exportToCSV() {
|
| 524 |
+
// 获取表格数据
|
| 525 |
+
const table = document.querySelector('#scan-results table');
|
| 526 |
+
let csv = [];
|
| 527 |
+
let rows = table.querySelectorAll('tr');
|
| 528 |
+
|
| 529 |
+
for (let i = 0; i < rows.length; i++) {
|
| 530 |
+
let row = [], cols = rows[i].querySelectorAll('td, th');
|
| 531 |
+
|
| 532 |
+
for (let j = 0; j < cols.length - 1; j++) { // 跳过最后一列(操作列)
|
| 533 |
+
// 获取单元格的文本内容,去除HTML标签
|
| 534 |
+
let text = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/,/g, ',');
|
| 535 |
+
row.push(text);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
csv.push(row.join(','));
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
// 下载CSV文件
|
| 542 |
+
const csvString = csv.join('\n');
|
| 543 |
+
const filename = '市场扫描结果_' + new Date().toISOString().slice(0, 10) + '.csv';
|
| 544 |
+
|
| 545 |
+
const blob = new Blob(['\uFEFF' + csvString], { type: 'text/csv;charset=utf-8;' });
|
| 546 |
+
const link = document.createElement('a');
|
| 547 |
+
link.href = URL.createObjectURL(blob);
|
| 548 |
+
link.download = filename;
|
| 549 |
+
|
| 550 |
+
link.style.display = 'none';
|
| 551 |
+
document.body.appendChild(link);
|
| 552 |
+
|
| 553 |
+
link.click();
|
| 554 |
+
|
| 555 |
+
document.body.removeChild(link);
|
| 556 |
+
}
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
// 添加到script部分
|
| 561 |
+
let currentTaskId = null; // 存储当前任务ID
|
| 562 |
+
|
| 563 |
+
// 取消按钮点击事件
|
| 564 |
+
$('#cancel-scan-btn').click(function() {
|
| 565 |
+
if (currentTaskId) {
|
| 566 |
+
cancelScan(currentTaskId);
|
| 567 |
+
} else {
|
| 568 |
+
$('#scan-loading').hide();
|
| 569 |
+
$('#scan-results').show();
|
| 570 |
+
}
|
| 571 |
+
});
|
| 572 |
+
|
| 573 |
+
// 修改启动成功处理
|
| 574 |
+
function handleStartSuccess(response) {
|
| 575 |
+
const taskId = response.task_id;
|
| 576 |
+
currentTaskId = taskId; // 保存当前任务ID
|
| 577 |
+
|
| 578 |
+
if (!taskId) {
|
| 579 |
+
showError('启动扫描任务失败:未获取到任务ID');
|
| 580 |
+
$('#scan-loading').hide();
|
| 581 |
+
$('#scan-results').show();
|
| 582 |
+
$('#scan-error-retry').show();
|
| 583 |
+
return;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// 启动轮询任务状态
|
| 587 |
+
pollScanStatus(taskId, 0);
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
</script>
|
| 591 |
+
{% endblock %}
|
templates/portfolio.html
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}投资组合 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-4">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-4">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header d-flex justify-content-between">
|
| 13 |
+
<h5 class="mb-0">我的投资组合</h5>
|
| 14 |
+
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addStockModal">
|
| 15 |
+
<i class="fas fa-plus"></i> 添加股票
|
| 16 |
+
</button>
|
| 17 |
+
</div>
|
| 18 |
+
<div class="card-body">
|
| 19 |
+
<div id="portfolio-empty" class="text-center py-4">
|
| 20 |
+
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
|
| 21 |
+
<p>您的投资组合还是空的,请添加股票</p>
|
| 22 |
+
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addStockModal">
|
| 23 |
+
<i class="fas fa-plus"></i> 添加股票
|
| 24 |
+
</button>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div id="portfolio-content" style="display: none;">
|
| 28 |
+
<div class="table-responsive">
|
| 29 |
+
<table class="table table-hover">
|
| 30 |
+
<thead>
|
| 31 |
+
<tr>
|
| 32 |
+
<th>代码</th>
|
| 33 |
+
<th>名称</th>
|
| 34 |
+
<th>行业</th>
|
| 35 |
+
<th>持仓比例</th>
|
| 36 |
+
<th>当前价格</th>
|
| 37 |
+
<th>今日涨跌</th>
|
| 38 |
+
<th>综合评分</th>
|
| 39 |
+
<th>建议</th>
|
| 40 |
+
<th>操作</th>
|
| 41 |
+
</tr>
|
| 42 |
+
</thead>
|
| 43 |
+
<tbody id="portfolio-table">
|
| 44 |
+
<!-- 投资组合数据将在JS中动态填充 -->
|
| 45 |
+
</tbody>
|
| 46 |
+
</table>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div id="portfolio-analysis" class="row mb-4" style="display: none;">
|
| 55 |
+
<div class="col-md-6">
|
| 56 |
+
<div class="card h-100">
|
| 57 |
+
<div class="card-header">
|
| 58 |
+
<h5 class="mb-0">投资组合评分</h5>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="card-body">
|
| 61 |
+
<div class="row">
|
| 62 |
+
<div class="col-md-4 text-center">
|
| 63 |
+
<div id="portfolio-score-chart"></div>
|
| 64 |
+
<h4 id="portfolio-score" class="mt-2">--</h4>
|
| 65 |
+
<p class="text-muted">综合评分</p>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="col-md-8">
|
| 68 |
+
<h5 class="mb-3">维度评分</h5>
|
| 69 |
+
<div class="mb-3">
|
| 70 |
+
<div class="d-flex justify-content-between mb-1">
|
| 71 |
+
<span>技术面</span>
|
| 72 |
+
<span id="technical-score">--/40</span>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="progress">
|
| 75 |
+
<div id="technical-progress" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
<div class="mb-3">
|
| 79 |
+
<div class="d-flex justify-content-between mb-1">
|
| 80 |
+
<span>基本面</span>
|
| 81 |
+
<span id="fundamental-score">--/40</span>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="progress">
|
| 84 |
+
<div id="fundamental-progress" class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="mb-3">
|
| 88 |
+
<div class="d-flex justify-content-between mb-1">
|
| 89 |
+
<span>资金面</span>
|
| 90 |
+
<span id="capital-flow-score">--/20</span>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="progress">
|
| 93 |
+
<div id="capital-flow-progress" class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="col-md-6">
|
| 102 |
+
<div class="card h-100">
|
| 103 |
+
<div class="card-header">
|
| 104 |
+
<h5 class="mb-0">行业分布</h5>
|
| 105 |
+
</div>
|
| 106 |
+
<div class="card-body">
|
| 107 |
+
<div id="industry-chart"></div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div id="portfolio-recommendations" class="row mb-4" style="display: none;">
|
| 114 |
+
<div class="col-12">
|
| 115 |
+
<div class="card">
|
| 116 |
+
<div class="card-header">
|
| 117 |
+
<h5 class="mb-0">投资建议</h5>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="card-body">
|
| 120 |
+
<ul class="list-group" id="recommendations-list">
|
| 121 |
+
<!-- 投资建议将在JS中动态填充 -->
|
| 122 |
+
</ul>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<!-- 添加股票模态框 -->
|
| 130 |
+
<div class="modal fade" id="addStockModal" tabindex="-1" aria-labelledby="addStockModalLabel" aria-hidden="true">
|
| 131 |
+
<div class="modal-dialog">
|
| 132 |
+
<div class="modal-content">
|
| 133 |
+
<div class="modal-header">
|
| 134 |
+
<h5 class="modal-title" id="addStockModalLabel">添加股票到投资组合</h5>
|
| 135 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 136 |
+
</div>
|
| 137 |
+
<div class="modal-body">
|
| 138 |
+
<form id="add-stock-form">
|
| 139 |
+
<div class="mb-3">
|
| 140 |
+
<label for="add-stock-code" class="form-label">股票代码</label>
|
| 141 |
+
<input type="text" class="form-control" id="add-stock-code" required>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="mb-3">
|
| 144 |
+
<label for="add-stock-weight" class="form-label">持仓比例 (%)</label>
|
| 145 |
+
<input type="number" class="form-control" id="add-stock-weight" min="1" max="100" value="10" required>
|
| 146 |
+
</div>
|
| 147 |
+
</form>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="modal-footer">
|
| 150 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
| 151 |
+
<button type="button" class="btn btn-primary" id="add-stock-btn">添加</button>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
{% endblock %}
|
| 157 |
+
|
| 158 |
+
{% block scripts %}
|
| 159 |
+
<script>
|
| 160 |
+
// 投资组合数据
|
| 161 |
+
let portfolio = [];
|
| 162 |
+
let portfolioAnalysis = null;
|
| 163 |
+
|
| 164 |
+
$(document).ready(function() {
|
| 165 |
+
// 从本地存储加载投资组合
|
| 166 |
+
loadPortfolio();
|
| 167 |
+
|
| 168 |
+
// 添加股票按钮点击事件
|
| 169 |
+
$('#add-stock-btn').click(function() {
|
| 170 |
+
addStockToPortfolio();
|
| 171 |
+
});
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
// 从本地存储加载投资组合
|
| 175 |
+
function loadPortfolio() {
|
| 176 |
+
const savedPortfolio = localStorage.getItem('portfolio');
|
| 177 |
+
if (savedPortfolio) {
|
| 178 |
+
portfolio = JSON.parse(savedPortfolio);
|
| 179 |
+
renderPortfolio();
|
| 180 |
+
analyzePortfolio();
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// 渲染投资组合
|
| 185 |
+
function renderPortfolio() {
|
| 186 |
+
if (portfolio.length === 0) {
|
| 187 |
+
$('#portfolio-empty').show();
|
| 188 |
+
$('#portfolio-content').hide();
|
| 189 |
+
$('#portfolio-analysis').hide();
|
| 190 |
+
$('#portfolio-recommendations').hide();
|
| 191 |
+
return;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
$('#portfolio-empty').hide();
|
| 195 |
+
$('#portfolio-content').show();
|
| 196 |
+
$('#portfolio-analysis').show();
|
| 197 |
+
$('#portfolio-recommendations').show();
|
| 198 |
+
|
| 199 |
+
let html = '';
|
| 200 |
+
portfolio.forEach((stock, index) => {
|
| 201 |
+
const scoreClass = getScoreColorClass(stock.score || 0);
|
| 202 |
+
const priceChangeClass = (stock.price_change || 0) >= 0 ? 'trend-up' : 'trend-down';
|
| 203 |
+
const priceChangeIcon = (stock.price_change || 0) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 204 |
+
|
| 205 |
+
// 显示加载状态或实际数据
|
| 206 |
+
const stockName = stock.loading ?
|
| 207 |
+
'<span class="text-muted"><i class="fas fa-spinner fa-pulse"></i> 加载中...</span>' :
|
| 208 |
+
(stock.stock_name || '未知');
|
| 209 |
+
|
| 210 |
+
const industryDisplay = stock.industry || '-';
|
| 211 |
+
|
| 212 |
+
html += `
|
| 213 |
+
<tr>
|
| 214 |
+
<td>${stock.stock_code}</td>
|
| 215 |
+
<td>${stockName}</td>
|
| 216 |
+
<td>${industryDisplay}</td>
|
| 217 |
+
<td>${stock.weight}%</td>
|
| 218 |
+
<td>${stock.price ? formatNumber(stock.price, 2) : '-'}</td>
|
| 219 |
+
<td class="${priceChangeClass}">${stock.price_change ? (priceChangeIcon + ' ' + formatPercent(stock.price_change, 2)) : '-'}</td>
|
| 220 |
+
<td><span class="badge ${scoreClass}">${stock.score || '-'}</span></td>
|
| 221 |
+
<td>${stock.recommendation || '-'}</td>
|
| 222 |
+
<td>
|
| 223 |
+
<div class="btn-group btn-group-sm" role="group">
|
| 224 |
+
<a href="/stock_detail/${stock.stock_code}" class="btn btn-outline-primary">
|
| 225 |
+
<i class="fas fa-chart-line"></i>
|
| 226 |
+
</a>
|
| 227 |
+
<button type="button" class="btn btn-outline-danger" onclick="removeStock(${index})">
|
| 228 |
+
<i class="fas fa-trash"></i>
|
| 229 |
+
</button>
|
| 230 |
+
</div>
|
| 231 |
+
</td>
|
| 232 |
+
</tr>
|
| 233 |
+
`;
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
$('#portfolio-table').html(html);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// 添加股票到投资组合
|
| 240 |
+
function addStockToPortfolio() {
|
| 241 |
+
const stockCode = $('#add-stock-code').val().trim();
|
| 242 |
+
const weight = parseInt($('#add-stock-weight').val() || 10);
|
| 243 |
+
|
| 244 |
+
if (!stockCode) {
|
| 245 |
+
showError('请输入股票代码');
|
| 246 |
+
return;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// 检查是否已存在
|
| 250 |
+
const existingIndex = portfolio.findIndex(s => s.stock_code === stockCode);
|
| 251 |
+
if (existingIndex >= 0) {
|
| 252 |
+
showError('此股票已在投资组合中');
|
| 253 |
+
return;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// 添加到投资组合
|
| 257 |
+
portfolio.push({
|
| 258 |
+
stock_code: stockCode,
|
| 259 |
+
weight: weight,
|
| 260 |
+
stock_name: '加载中...',
|
| 261 |
+
industry: '-',
|
| 262 |
+
price: null,
|
| 263 |
+
price_change: null,
|
| 264 |
+
score: null,
|
| 265 |
+
recommendation: null,
|
| 266 |
+
loading: true // 添加加载状态标志
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// 保存到本地存储
|
| 270 |
+
savePortfolio();
|
| 271 |
+
|
| 272 |
+
// 关闭模态框
|
| 273 |
+
$('#addStockModal').modal('hide');
|
| 274 |
+
|
| 275 |
+
// 重置表单
|
| 276 |
+
$('#add-stock-form')[0].reset();
|
| 277 |
+
|
| 278 |
+
// 获取股票数据
|
| 279 |
+
fetchStockData(stockCode);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// 添加重试加载功能
|
| 283 |
+
function retryFetchStockData(stockCode) {
|
| 284 |
+
showInfo(`正在重新获取 ${stockCode} 的数据...`);
|
| 285 |
+
fetchStockData(stockCode);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// 在渲染函数中添加重试按钮
|
| 289 |
+
html += `
|
| 290 |
+
<tr>
|
| 291 |
+
<td>${stock.stock_code}</td>
|
| 292 |
+
<td>${stockName} ${stock.stock_name === '获取失败' ?
|
| 293 |
+
`<button class="btn btn-sm btn-link p-0 ml-2" onclick="retryFetchStockData('${stock.stock_code}')">
|
| 294 |
+
<i class="fas fa-sync-alt"></i> 重试
|
| 295 |
+
</button>` : ''}
|
| 296 |
+
</td>
|
| 297 |
+
...
|
| 298 |
+
`;
|
| 299 |
+
|
| 300 |
+
// 获取股票数据
|
| 301 |
+
function fetchStockData(stockCode) {
|
| 302 |
+
const index = portfolio.findIndex(s => s.stock_code === stockCode);
|
| 303 |
+
if (index < 0) return;
|
| 304 |
+
|
| 305 |
+
// 显示加载状态
|
| 306 |
+
portfolio[index].loading = true;
|
| 307 |
+
savePortfolio();
|
| 308 |
+
renderPortfolio();
|
| 309 |
+
|
| 310 |
+
$.ajax({
|
| 311 |
+
url: '/analyze',
|
| 312 |
+
type: 'POST',
|
| 313 |
+
contentType: 'application/json',
|
| 314 |
+
data: JSON.stringify({
|
| 315 |
+
stock_codes: [stockCode],
|
| 316 |
+
market_type: 'A'
|
| 317 |
+
}),
|
| 318 |
+
success: function(response) {
|
| 319 |
+
|
| 320 |
+
if (response.results && response.results.length > 0) {
|
| 321 |
+
const result = response.results[0];
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
// 确保使用null检查来处理缺失值
|
| 325 |
+
portfolio[index].stock_name = result.stock_name || '未知';
|
| 326 |
+
portfolio[index].industry = result.industry || '未知';
|
| 327 |
+
portfolio[index].price = result.price || 0;
|
| 328 |
+
portfolio[index].price_change = result.price_change || 0;
|
| 329 |
+
portfolio[index].score = result.score || 0;
|
| 330 |
+
portfolio[index].recommendation = result.recommendation || '-';
|
| 331 |
+
portfolio[index].loading = false; // 清除加载状态
|
| 332 |
+
|
| 333 |
+
// 保存更新后的投资组合
|
| 334 |
+
savePortfolio();
|
| 335 |
+
|
| 336 |
+
// 分析投资组合
|
| 337 |
+
analyzePortfolio();
|
| 338 |
+
|
| 339 |
+
showSuccess(`已添加 ${result.stock_name || stockCode} 到投资组合`);
|
| 340 |
+
} else {
|
| 341 |
+
portfolio[index].stock_name = '数据获取失败';
|
| 342 |
+
portfolio[index].loading = false;
|
| 343 |
+
savePortfolio();
|
| 344 |
+
renderPortfolio();
|
| 345 |
+
showError(`获取股票 ${stockCode} 数据失败`);
|
| 346 |
+
}
|
| 347 |
+
},
|
| 348 |
+
error: function(error) {
|
| 349 |
+
portfolio[index].stock_name = '获取失败';
|
| 350 |
+
portfolio[index].loading = false;
|
| 351 |
+
savePortfolio();
|
| 352 |
+
renderPortfolio();
|
| 353 |
+
showError(`获取股票 ${stockCode} 数据失败`);
|
| 354 |
+
}
|
| 355 |
+
});
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// 从投资组合中移除股票
|
| 359 |
+
function removeStock(index) {
|
| 360 |
+
if (confirm('确定要从投资组合中移除此股票吗?')) {
|
| 361 |
+
portfolio.splice(index, 1);
|
| 362 |
+
savePortfolio();
|
| 363 |
+
renderPortfolio();
|
| 364 |
+
analyzePortfolio();
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// 保存投资组合到本地存储
|
| 369 |
+
function savePortfolio() {
|
| 370 |
+
localStorage.setItem('portfolio', JSON.stringify(portfolio));
|
| 371 |
+
renderPortfolio();
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
// 分析投资组合
|
| 376 |
+
function analyzePortfolio() {
|
| 377 |
+
if (portfolio.length === 0) return;
|
| 378 |
+
|
| 379 |
+
// 计算投资组合评分
|
| 380 |
+
let totalScore = 0;
|
| 381 |
+
let totalWeight = 0;
|
| 382 |
+
let industriesMap = {};
|
| 383 |
+
|
| 384 |
+
portfolio.forEach(stock => {
|
| 385 |
+
if (stock.score) {
|
| 386 |
+
totalScore += stock.score * stock.weight;
|
| 387 |
+
totalWeight += stock.weight;
|
| 388 |
+
|
| 389 |
+
// 统计行业分布
|
| 390 |
+
const industry = stock.industry || '其他';
|
| 391 |
+
if (industriesMap[industry]) {
|
| 392 |
+
industriesMap[industry] += stock.weight;
|
| 393 |
+
} else {
|
| 394 |
+
industriesMap[industry] = stock.weight;
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
// 确保总权重不为零
|
| 400 |
+
if (totalWeight > 0) {
|
| 401 |
+
const portfolioScore = Math.round(totalScore / totalWeight);
|
| 402 |
+
|
| 403 |
+
// 更新评分显示
|
| 404 |
+
$('#portfolio-score').text(portfolioScore);
|
| 405 |
+
|
| 406 |
+
// 简化的维度评分计算
|
| 407 |
+
const technicalScore = Math.round(portfolioScore * 0.4);
|
| 408 |
+
const fundamentalScore = Math.round(portfolioScore * 0.4);
|
| 409 |
+
const capitalFlowScore = Math.round(portfolioScore * 0.2);
|
| 410 |
+
|
| 411 |
+
$('#technical-score').text(technicalScore + '/40');
|
| 412 |
+
$('#fundamental-score').text(fundamentalScore + '/40');
|
| 413 |
+
$('#capital-flow-score').text(capitalFlowScore + '/20');
|
| 414 |
+
|
| 415 |
+
$('#technical-progress').css('width', (technicalScore / 40 * 100) + '%');
|
| 416 |
+
$('#fundamental-progress').css('width', (fundamentalScore / 40 * 100) + '%');
|
| 417 |
+
$('#capital-flow-progress').css('width', (capitalFlowScore / 20 * 100) + '%');
|
| 418 |
+
|
| 419 |
+
// 更新投资组合评分图表
|
| 420 |
+
renderPortfolioScoreChart(portfolioScore);
|
| 421 |
+
|
| 422 |
+
// 更新行业分布图表
|
| 423 |
+
renderIndustryChart(industriesMap);
|
| 424 |
+
|
| 425 |
+
// 生成投资建议
|
| 426 |
+
generateRecommendations(portfolioScore);
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
// 渲染投资组合评分图表
|
| 431 |
+
function renderPortfolioScoreChart(score) {
|
| 432 |
+
const options = {
|
| 433 |
+
series: [score],
|
| 434 |
+
chart: {
|
| 435 |
+
height: 150,
|
| 436 |
+
type: 'radialBar',
|
| 437 |
+
},
|
| 438 |
+
plotOptions: {
|
| 439 |
+
radialBar: {
|
| 440 |
+
hollow: {
|
| 441 |
+
size: '70%',
|
| 442 |
+
},
|
| 443 |
+
dataLabels: {
|
| 444 |
+
show: false
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
},
|
| 448 |
+
colors: [getScoreColor(score)],
|
| 449 |
+
stroke: {
|
| 450 |
+
lineCap: 'round'
|
| 451 |
+
}
|
| 452 |
+
};
|
| 453 |
+
|
| 454 |
+
// 清除旧图表
|
| 455 |
+
$('#portfolio-score-chart').empty();
|
| 456 |
+
|
| 457 |
+
const chart = new ApexCharts(document.querySelector("#portfolio-score-chart"), options);
|
| 458 |
+
chart.render();
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// 渲染行业分布图表
|
| 462 |
+
function renderIndustryChart(industriesMap) {
|
| 463 |
+
// 转换数据格式为图表所需
|
| 464 |
+
const seriesData = [];
|
| 465 |
+
const labels = [];
|
| 466 |
+
|
| 467 |
+
for (const industry in industriesMap) {
|
| 468 |
+
if (industriesMap.hasOwnProperty(industry)) {
|
| 469 |
+
seriesData.push(industriesMap[industry]);
|
| 470 |
+
labels.push(industry);
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
const options = {
|
| 475 |
+
series: seriesData,
|
| 476 |
+
chart: {
|
| 477 |
+
type: 'pie',
|
| 478 |
+
height: 300
|
| 479 |
+
},
|
| 480 |
+
labels: labels,
|
| 481 |
+
responsive: [{
|
| 482 |
+
breakpoint: 480,
|
| 483 |
+
options: {
|
| 484 |
+
chart: {
|
| 485 |
+
height: 200
|
| 486 |
+
},
|
| 487 |
+
legend: {
|
| 488 |
+
position: 'bottom'
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
}],
|
| 492 |
+
tooltip: {
|
| 493 |
+
y: {
|
| 494 |
+
formatter: function(value) {
|
| 495 |
+
return value + '%';
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
};
|
| 500 |
+
|
| 501 |
+
// 清除旧图表
|
| 502 |
+
$('#industry-chart').empty();
|
| 503 |
+
|
| 504 |
+
const chart = new ApexCharts(document.querySelector("#industry-chart"), options);
|
| 505 |
+
chart.render();
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// 生成投资建议
|
| 509 |
+
function generateRecommendations(portfolioScore) {
|
| 510 |
+
let recommendations = [];
|
| 511 |
+
|
| 512 |
+
// 根据总分生成基本建议
|
| 513 |
+
if (portfolioScore >= 80) {
|
| 514 |
+
recommendations.push({
|
| 515 |
+
text: '您的投资组合整体评级优秀,当前市场环境下建议保持较高仓位',
|
| 516 |
+
type: 'success'
|
| 517 |
+
});
|
| 518 |
+
} else if (portfolioScore >= 60) {
|
| 519 |
+
recommendations.push({
|
| 520 |
+
text: '您的投资组合整体评级良好,可以考虑适度增加仓位',
|
| 521 |
+
type: 'primary'
|
| 522 |
+
});
|
| 523 |
+
} else if (portfolioScore >= 40) {
|
| 524 |
+
recommendations.push({
|
| 525 |
+
text: '您的投资组合整体评级一般,建议持币观望,等待更好的入场时机',
|
| 526 |
+
type: 'warning'
|
| 527 |
+
});
|
| 528 |
+
} else {
|
| 529 |
+
recommendations.push({
|
| 530 |
+
text: '您的投资组合整体评级较弱,建议减仓规避风险',
|
| 531 |
+
type: 'danger'
|
| 532 |
+
});
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
// 检查行业集中度
|
| 536 |
+
const industries = {};
|
| 537 |
+
let totalWeight = 0;
|
| 538 |
+
|
| 539 |
+
portfolio.forEach(stock => {
|
| 540 |
+
const industry = stock.industry || '其他';
|
| 541 |
+
if (industries[industry]) {
|
| 542 |
+
industries[industry] += stock.weight;
|
| 543 |
+
} else {
|
| 544 |
+
industries[industry] = stock.weight;
|
| 545 |
+
}
|
| 546 |
+
totalWeight += stock.weight;
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
// 计算行业集中度
|
| 550 |
+
let maxIndustryWeight = 0;
|
| 551 |
+
let maxIndustry = '';
|
| 552 |
+
|
| 553 |
+
for (const industry in industries) {
|
| 554 |
+
if (industries[industry] > maxIndustryWeight) {
|
| 555 |
+
maxIndustryWeight = industries[industry];
|
| 556 |
+
maxIndustry = industry;
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
const industryConcentration = maxIndustryWeight / totalWeight;
|
| 561 |
+
|
| 562 |
+
if (industryConcentration > 0.5) {
|
| 563 |
+
recommendations.push({
|
| 564 |
+
text: `行业集中度较高,${maxIndustry}行业占比${Math.round(industryConcentration * 100)}%,建议适当分散投资降低非系统性风险`,
|
| 565 |
+
type: 'warning'
|
| 566 |
+
});
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// 检查需要调整的个股
|
| 570 |
+
const weakStocks = portfolio.filter(stock => stock.score && stock.score < 40);
|
| 571 |
+
if (weakStocks.length > 0) {
|
| 572 |
+
const stockNames = weakStocks.map(s => `${s.stock_name}(${s.stock_code})`).join('、');
|
| 573 |
+
recommendations.push({
|
| 574 |
+
text: `以下个股评分较低,建议考虑调整:${stockNames}`,
|
| 575 |
+
type: 'danger'
|
| 576 |
+
});
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
const strongStocks = portfolio.filter(stock => stock.score && stock.score > 70);
|
| 580 |
+
if (strongStocks.length > 0 && portfolioScore < 60) {
|
| 581 |
+
const stockNames = strongStocks.map(s => `${s.stock_name}(${s.stock_code})`).join('、');
|
| 582 |
+
recommendations.push({
|
| 583 |
+
text: `以下个股表现强势,可考虑增加配置比例:${stockNames}`,
|
| 584 |
+
type: 'success'
|
| 585 |
+
});
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// 渲染建议
|
| 589 |
+
let html = '';
|
| 590 |
+
recommendations.forEach(rec => {
|
| 591 |
+
html += `<li class="list-group-item list-group-item-${rec.type}">${rec.text}</li>`;
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
$('#recommendations-list').html(html);
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
// 获取评分颜色
|
| 598 |
+
function getScoreColor(score) {
|
| 599 |
+
if (score >= 80) return '#28a745'; // 绿色
|
| 600 |
+
if (score >= 60) return '#007bff'; // 蓝色
|
| 601 |
+
if (score >= 40) return '#ffc107'; // 黄色
|
| 602 |
+
return '#dc3545'; // 红色
|
| 603 |
+
}
|
| 604 |
+
</script>
|
| 605 |
+
{% endblock %}
|
templates/qa.html
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}智能问答 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-3">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header py-2">
|
| 13 |
+
<h5 class="mb-0">智能问答</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body py-2">
|
| 16 |
+
<form id="qa-form" class="row g-2">
|
| 17 |
+
<div class="col-md-4">
|
| 18 |
+
<div class="input-group input-group-sm">
|
| 19 |
+
<span class="input-group-text">股票代码</span>
|
| 20 |
+
<input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="col-md-3">
|
| 24 |
+
<div class="input-group input-group-sm">
|
| 25 |
+
<span class="input-group-text">市场</span>
|
| 26 |
+
<select class="form-select" id="market-type">
|
| 27 |
+
<option value="A" selected>A股</option>
|
| 28 |
+
<option value="HK">港股</option>
|
| 29 |
+
<option value="US">美股</option>
|
| 30 |
+
</select>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="col-md-3">
|
| 34 |
+
<button type="submit" class="btn btn-primary btn-sm w-100">
|
| 35 |
+
<i class="fas fa-info-circle"></i> 选择股票
|
| 36 |
+
</button>
|
| 37 |
+
</div>
|
| 38 |
+
</form>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="row" id="chat-container" style="display: none;">
|
| 45 |
+
<div class="col-md-3">
|
| 46 |
+
<div class="card mb-3">
|
| 47 |
+
<div class="card-header py-2">
|
| 48 |
+
<h5 class="mb-0" id="stock-info-header">股票信息</h5>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="card-body">
|
| 51 |
+
<h4 id="selected-stock-name" class="mb-1">--</h4>
|
| 52 |
+
<p id="selected-stock-code" class="text-muted mb-3">--</p>
|
| 53 |
+
<p class="mb-1"><span class="text-muted">行业:</span> <span id="selected-stock-industry">--</span></p>
|
| 54 |
+
<p class="mb-1"><span class="text-muted">现价:</span> <span id="selected-stock-price">--</span></p>
|
| 55 |
+
<p class="mb-1"><span class="text-muted">涨跌幅:</span> <span id="selected-stock-change">--</span></p>
|
| 56 |
+
<hr class="my-3">
|
| 57 |
+
<h6>常见问题</h6>
|
| 58 |
+
<div class="list-group list-group-flush">
|
| 59 |
+
<button class="list-group-item list-group-item-action common-question" data-question="这只股票的主要支撑位是多少?">主要支撑位分析</button>
|
| 60 |
+
<button class="list-group-item list-group-item-action common-question" data-question="该股票近期的技术面走势如何?">技术面走势分析</button>
|
| 61 |
+
<button class="list-group-item list-group-item-action common-question" data-question="这只股票的基本面情况如何?">基本面情况分析</button>
|
| 62 |
+
<button class="list-group-item list-group-item-action common-question" data-question="该股票主力资金最近的流入情况?">主力资金流向</button>
|
| 63 |
+
<button class="list-group-item list-group-item-action common-question" data-question="这只股票近期有哪些重要事件?">近期重要事件</button>
|
| 64 |
+
<button class="list-group-item list-group-item-action common-question" data-question="您对这只股票有什么投资建议?">综合投资建议</button>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="col-md-9">
|
| 70 |
+
<div class="card mb-3">
|
| 71 |
+
<div class="card-header py-2">
|
| 72 |
+
<h5 class="mb-0">与AI助手对话</h5>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="card-body p-0">
|
| 75 |
+
<div id="chat-messages" class="p-3" style="height: 400px; overflow-y: auto;">
|
| 76 |
+
<div class="chat-message system-message">
|
| 77 |
+
<div class="message-content">
|
| 78 |
+
<p>您好!我是股票分析AI助手,请输入您想了解的关于当前股票的问题。</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="p-3 border-top">
|
| 83 |
+
<form id="question-form" class="d-flex">
|
| 84 |
+
<input type="text" id="question-input" class="form-control me-2" placeholder="输入您的问题..." required>
|
| 85 |
+
<button type="submit" class="btn btn-primary">
|
| 86 |
+
<i class="fas fa-paper-plane"></i>
|
| 87 |
+
</button>
|
| 88 |
+
</form>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div id="loading-panel" class="text-center py-5" style="display: none;">
|
| 96 |
+
<div class="spinner-border text-primary" role="status">
|
| 97 |
+
<span class="visually-hidden">Loading...</span>
|
| 98 |
+
</div>
|
| 99 |
+
<p class="mt-3 mb-0">正在获取股票数据...</p>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
{% endblock %}
|
| 103 |
+
|
| 104 |
+
{% block head %}
|
| 105 |
+
<style>
|
| 106 |
+
.chat-message {
|
| 107 |
+
margin-bottom: 15px;
|
| 108 |
+
display: flex;
|
| 109 |
+
flex-direction: column;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.user-message {
|
| 113 |
+
align-items: flex-end;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.system-message {
|
| 117 |
+
align-items: flex-start;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.message-content {
|
| 121 |
+
max-width: 80%;
|
| 122 |
+
padding: 10px 15px;
|
| 123 |
+
border-radius: 15px;
|
| 124 |
+
position: relative;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.user-message .message-content {
|
| 128 |
+
background-color: #007bff;
|
| 129 |
+
color: white;
|
| 130 |
+
border-bottom-right-radius: 0;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.system-message .message-content {
|
| 134 |
+
background-color: #f1f1f1;
|
| 135 |
+
color: #333;
|
| 136 |
+
border-bottom-left-radius: 0;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.message-content p {
|
| 140 |
+
margin-bottom: 0.5rem;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.message-content p:last-child {
|
| 144 |
+
margin-bottom: 0;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.message-time {
|
| 148 |
+
font-size: 0.75rem;
|
| 149 |
+
color: #aaa;
|
| 150 |
+
margin-top: 4px;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.common-question {
|
| 154 |
+
padding: 0.5rem 0.75rem;
|
| 155 |
+
font-size: 0.875rem;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.keyword {
|
| 159 |
+
color: #2c7be5;
|
| 160 |
+
font-weight: 600;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.term {
|
| 164 |
+
color: #d6336c;
|
| 165 |
+
font-weight: 500;
|
| 166 |
+
padding: 0 2px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.price {
|
| 170 |
+
color: #00a47c;
|
| 171 |
+
font-family: 'Roboto Mono', monospace;
|
| 172 |
+
background: #f3faf8;
|
| 173 |
+
padding: 2px 4px;
|
| 174 |
+
border-radius: 3px;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.trend-up {
|
| 178 |
+
color: #28a745;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.trend-down {
|
| 182 |
+
color: #dc3545;
|
| 183 |
+
}
|
| 184 |
+
</style>
|
| 185 |
+
{% endblock %}
|
| 186 |
+
|
| 187 |
+
{% block scripts %}
|
| 188 |
+
<script>
|
| 189 |
+
let selectedStock = {
|
| 190 |
+
code: '',
|
| 191 |
+
name: '',
|
| 192 |
+
market_type: 'A'
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
$(document).ready(function() {
|
| 196 |
+
// 选择股票表单提交
|
| 197 |
+
$('#qa-form').submit(function(e) {
|
| 198 |
+
e.preventDefault();
|
| 199 |
+
const stockCode = $('#stock-code').val().trim();
|
| 200 |
+
const marketType = $('#market-type').val();
|
| 201 |
+
|
| 202 |
+
if (!stockCode) {
|
| 203 |
+
showError('请输入股票代码!');
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
selectStock(stockCode, marketType);
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
// 问题表单提交
|
| 211 |
+
$('#question-form').submit(function(e) {
|
| 212 |
+
e.preventDefault();
|
| 213 |
+
const question = $('#question-input').val().trim();
|
| 214 |
+
|
| 215 |
+
if (!question) {
|
| 216 |
+
return;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
if (!selectedStock.code) {
|
| 220 |
+
showError('请先选择一只股票');
|
| 221 |
+
return;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
addUserMessage(question);
|
| 225 |
+
$('#question-input').val('');
|
| 226 |
+
askQuestion(question);
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// 常见问题点击
|
| 230 |
+
$('.common-question').click(function() {
|
| 231 |
+
const question = $(this).data('question');
|
| 232 |
+
|
| 233 |
+
if (!selectedStock.code) {
|
| 234 |
+
showError('请先选择一只股票');
|
| 235 |
+
return;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
$('#question-input').val(question);
|
| 239 |
+
$('#question-form').submit();
|
| 240 |
+
});
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
function selectStock(stockCode, marketType) {
|
| 244 |
+
$('#loading-panel').show();
|
| 245 |
+
$('#chat-container').hide();
|
| 246 |
+
|
| 247 |
+
// 重置对话区域
|
| 248 |
+
$('#chat-messages').html(`
|
| 249 |
+
<div class="chat-message system-message">
|
| 250 |
+
<div class="message-content">
|
| 251 |
+
<p>您好!我是股票分析AI助手,请输入您想了解的关于当前股票的问题。</p>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
`);
|
| 255 |
+
|
| 256 |
+
// 获取股票基本信息
|
| 257 |
+
$.ajax({
|
| 258 |
+
url: '/analyze',
|
| 259 |
+
type: 'POST',
|
| 260 |
+
contentType: 'application/json',
|
| 261 |
+
data: JSON.stringify({
|
| 262 |
+
stock_codes: [stockCode],
|
| 263 |
+
market_type: marketType
|
| 264 |
+
}),
|
| 265 |
+
success: function(response) {
|
| 266 |
+
$('#loading-panel').hide();
|
| 267 |
+
|
| 268 |
+
if (response.results && response.results.length > 0) {
|
| 269 |
+
const stockInfo = response.results[0];
|
| 270 |
+
|
| 271 |
+
// 保存选中的股票信息
|
| 272 |
+
selectedStock = {
|
| 273 |
+
code: stockCode,
|
| 274 |
+
name: stockInfo.stock_name || '未���',
|
| 275 |
+
market_type: marketType,
|
| 276 |
+
industry: stockInfo.industry || '未知',
|
| 277 |
+
price: stockInfo.price || 0,
|
| 278 |
+
price_change: stockInfo.price_change || 0
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
// 更新股票信息区域
|
| 282 |
+
updateStockInfo();
|
| 283 |
+
|
| 284 |
+
// 显示聊天界面
|
| 285 |
+
$('#chat-container').show();
|
| 286 |
+
|
| 287 |
+
// 欢迎消息
|
| 288 |
+
addSystemMessage(`我已加载 ${selectedStock.name}(${selectedStock.code}) 的数据,您可以问我关于这只股票的问题。`);
|
| 289 |
+
} else {
|
| 290 |
+
showError('未找到股票信息,请检查股票代码是否正确');
|
| 291 |
+
}
|
| 292 |
+
},
|
| 293 |
+
error: function(xhr, status, error) {
|
| 294 |
+
$('#loading-panel').hide();
|
| 295 |
+
let errorMsg = '获取股票信息失败';
|
| 296 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 297 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 298 |
+
} else if (error) {
|
| 299 |
+
errorMsg += ': ' + error;
|
| 300 |
+
}
|
| 301 |
+
showError(errorMsg);
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
function updateStockInfo() {
|
| 307 |
+
// 更新股票信息区域
|
| 308 |
+
$('#stock-info-header').text(selectedStock.name);
|
| 309 |
+
$('#selected-stock-name').text(selectedStock.name);
|
| 310 |
+
$('#selected-stock-code').text(selectedStock.code);
|
| 311 |
+
$('#selected-stock-industry').text(selectedStock.industry);
|
| 312 |
+
$('#selected-stock-price').text('¥' + formatNumber(selectedStock.price, 2));
|
| 313 |
+
|
| 314 |
+
const priceChangeClass = selectedStock.price_change >= 0 ? 'trend-up' : 'trend-down';
|
| 315 |
+
const priceChangeIcon = selectedStock.price_change >= 0 ? '<i class="fas fa-caret-up"></i> ' : '<i class="fas fa-caret-down"></i> ';
|
| 316 |
+
$('#selected-stock-change').html(`<span class="${priceChangeClass}">${priceChangeIcon}${formatPercent(selectedStock.price_change, 2)}</span>`);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function askQuestion(question) {
|
| 320 |
+
// 显示思考中消息
|
| 321 |
+
const thinkingMessageId = 'thinking-' + Date.now();
|
| 322 |
+
addSystemMessage('<i class="fas fa-spinner fa-pulse"></i> 正在思考...', thinkingMessageId);
|
| 323 |
+
|
| 324 |
+
// 发送问题到API
|
| 325 |
+
$.ajax({
|
| 326 |
+
url: '/api/qa',
|
| 327 |
+
type: 'POST',
|
| 328 |
+
contentType: 'application/json',
|
| 329 |
+
data: JSON.stringify({
|
| 330 |
+
stock_code: selectedStock.code,
|
| 331 |
+
question: question,
|
| 332 |
+
market_type: selectedStock.market_type
|
| 333 |
+
}),
|
| 334 |
+
success: function(response) {
|
| 335 |
+
// 移除思考中消息
|
| 336 |
+
$(`#${thinkingMessageId}`).remove();
|
| 337 |
+
|
| 338 |
+
// 添加回答
|
| 339 |
+
addSystemMessage(formatAnswer(response.answer));
|
| 340 |
+
|
| 341 |
+
// 滚动到底部
|
| 342 |
+
scrollToBottom();
|
| 343 |
+
},
|
| 344 |
+
error: function(xhr, status, error) {
|
| 345 |
+
// 移除思考中消息
|
| 346 |
+
$(`#${thinkingMessageId}`).remove();
|
| 347 |
+
|
| 348 |
+
// 添加错误消息
|
| 349 |
+
let errorMsg = '无法回答您的问题';
|
| 350 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 351 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 352 |
+
} else if (error) {
|
| 353 |
+
errorMsg += ': ' + error;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
addSystemMessage(`<span class="text-danger">${errorMsg}</span>`);
|
| 357 |
+
|
| 358 |
+
// 滚动到底部
|
| 359 |
+
scrollToBottom();
|
| 360 |
+
}
|
| 361 |
+
});
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
function addUserMessage(message) {
|
| 365 |
+
const time = new Date().toLocaleTimeString();
|
| 366 |
+
|
| 367 |
+
const messageHtml = `
|
| 368 |
+
<div class="chat-message user-message">
|
| 369 |
+
<div class="message-content">
|
| 370 |
+
<p>${message}</p>
|
| 371 |
+
</div>
|
| 372 |
+
<div class="message-time">${time}</div>
|
| 373 |
+
</div>
|
| 374 |
+
`;
|
| 375 |
+
|
| 376 |
+
$('#chat-messages').append(messageHtml);
|
| 377 |
+
scrollToBottom();
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function addSystemMessage(message, id = null) {
|
| 381 |
+
const time = new Date().toLocaleTimeString();
|
| 382 |
+
const idAttribute = id ? `id="${id}"` : '';
|
| 383 |
+
|
| 384 |
+
const messageHtml = `
|
| 385 |
+
<div class="chat-message system-message" ${idAttribute}>
|
| 386 |
+
<div class="message-content">
|
| 387 |
+
<p>${message}</p>
|
| 388 |
+
</div>
|
| 389 |
+
<div class="message-time">${time}</div>
|
| 390 |
+
</div>
|
| 391 |
+
`;
|
| 392 |
+
|
| 393 |
+
$('#chat-messages').append(messageHtml);
|
| 394 |
+
scrollToBottom();
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
function scrollToBottom() {
|
| 398 |
+
const chatContainer = document.getElementById('chat-messages');
|
| 399 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function formatAnswer(text) {
|
| 403 |
+
if (!text) return '';
|
| 404 |
+
|
| 405 |
+
// First, make the text safe for HTML
|
| 406 |
+
const safeText = text
|
| 407 |
+
.replace(/&/g, '&')
|
| 408 |
+
.replace(/</g, '<')
|
| 409 |
+
.replace(/>/g, '>');
|
| 410 |
+
|
| 411 |
+
// Replace basic Markdown elements
|
| 412 |
+
let formatted = safeText
|
| 413 |
+
// Bold text with ** or __
|
| 414 |
+
.replace(/\*\*(.*?)\*\*/g, '<strong class="keyword">$1</strong>')
|
| 415 |
+
.replace(/__(.*?)__/g, '<strong>$1</strong>')
|
| 416 |
+
|
| 417 |
+
// Italic text with * or _
|
| 418 |
+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
| 419 |
+
.replace(/_(.*?)_/g, '<em>$1</em>')
|
| 420 |
+
|
| 421 |
+
// Headers - only h4, h5, h6 for chat
|
| 422 |
+
.replace(/^#### (.*?)$/gm, '<h6>$1</h6>')
|
| 423 |
+
.replace(/^### (.*?)$/gm, '<h6>$1</h6>')
|
| 424 |
+
.replace(/^## (.*?)$/gm, '<h6>$1</h6>')
|
| 425 |
+
.replace(/^# (.*?)$/gm, '<h6>$1</h6>')
|
| 426 |
+
|
| 427 |
+
// Apply special styling to financial terms
|
| 428 |
+
.replace(/支撑位/g, '<span class="keyword">支撑位</span>')
|
| 429 |
+
.replace(/压力位/g, '<span class="keyword">压力位</span>')
|
| 430 |
+
.replace(/趋势/g, '<span class="keyword">趋势</span>')
|
| 431 |
+
.replace(/均线/g, '<span class="keyword">均线</span>')
|
| 432 |
+
.replace(/MACD/g, '<span class="term">MACD</span>')
|
| 433 |
+
.replace(/RSI/g, '<span class="term">RSI</span>')
|
| 434 |
+
.replace(/KDJ/g, '<span class="term">KDJ</span>')
|
| 435 |
+
|
| 436 |
+
// Highlight price patterns and movements
|
| 437 |
+
.replace(/([上涨升])/g, '<span class="trend-up">$1</span>')
|
| 438 |
+
.replace(/([下跌降])/g, '<span class="trend-down">$1</span>')
|
| 439 |
+
.replace(/(买入|做多|多头|突破)/g, '<span class="trend-up">$1</span>')
|
| 440 |
+
.replace(/(卖出|做空|空头|跌破)/g, '<span class="trend-down">$1</span>')
|
| 441 |
+
|
| 442 |
+
// Highlight price values (matches patterns like 31.25, 120.50)
|
| 443 |
+
.replace(/(\d+\.\d{2})/g, '<span class="price">$1</span>')
|
| 444 |
+
|
| 445 |
+
// Convert line breaks to paragraph tags
|
| 446 |
+
.replace(/\n\n+/g, '</p><p class="mb-2">')
|
| 447 |
+
.replace(/\n/g, '<br>');
|
| 448 |
+
|
| 449 |
+
// Wrap in paragraph tags for consistent styling
|
| 450 |
+
return '<p class="mb-2">' + formatted + '</p>';
|
| 451 |
+
}
|
| 452 |
+
</script>
|
| 453 |
+
{% endblock %}
|
templates/risk_monitor.html
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}风险监控 - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-3">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header py-2">
|
| 13 |
+
<h5 class="mb-0">风险监控</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body py-2">
|
| 16 |
+
<ul class="nav nav-tabs" id="risk-tabs" role="tablist">
|
| 17 |
+
<li class="nav-item" role="presentation">
|
| 18 |
+
<button class="nav-link active" id="stock-risk-tab" data-bs-toggle="tab" data-bs-target="#stock-risk" type="button" role="tab" aria-controls="stock-risk" aria-selected="true">个股风险</button>
|
| 19 |
+
</li>
|
| 20 |
+
<li class="nav-item" role="presentation">
|
| 21 |
+
<button class="nav-link" id="portfolio-risk-tab" data-bs-toggle="tab" data-bs-target="#portfolio-risk" type="button" role="tab" aria-controls="portfolio-risk" aria-selected="false">组合风险</button>
|
| 22 |
+
</li>
|
| 23 |
+
</ul>
|
| 24 |
+
<div class="tab-content mt-3" id="risk-tabs-content">
|
| 25 |
+
<div class="tab-pane fade show active" id="stock-risk" role="tabpanel" aria-labelledby="stock-risk-tab">
|
| 26 |
+
<form id="stock-risk-form" class="row g-2">
|
| 27 |
+
<div class="col-md-4">
|
| 28 |
+
<div class="input-group input-group-sm">
|
| 29 |
+
<span class="input-group-text">股票代码</span>
|
| 30 |
+
<input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="col-md-3">
|
| 34 |
+
<div class="input-group input-group-sm">
|
| 35 |
+
<span class="input-group-text">市场</span>
|
| 36 |
+
<select class="form-select" id="market-type">
|
| 37 |
+
<option value="A" selected>A股</option>
|
| 38 |
+
<option value="HK">港股</option>
|
| 39 |
+
<option value="US">美股</option>
|
| 40 |
+
</select>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="col-md-3">
|
| 44 |
+
<button type="submit" class="btn btn-primary btn-sm w-100">
|
| 45 |
+
<i class="fas fa-search"></i> 分析风险
|
| 46 |
+
</button>
|
| 47 |
+
</div>
|
| 48 |
+
</form>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="tab-pane fade" id="portfolio-risk" role="tabpanel" aria-labelledby="portfolio-risk-tab">
|
| 51 |
+
<div class="alert alert-info">
|
| 52 |
+
<i class="fas fa-info-circle"></i> 分析投资组合的整体风险,识别高风险股票和风险集中度。
|
| 53 |
+
</div>
|
| 54 |
+
<button id="analyze-portfolio-btn" class="btn btn-primary btn-sm">
|
| 55 |
+
<i class="fas fa-briefcase"></i> 分析我的投资组合
|
| 56 |
+
</button>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div id="loading-panel" class="text-center py-5" style="display: none;">
|
| 65 |
+
<div class="spinner-border text-primary" role="status">
|
| 66 |
+
<span class="visually-hidden">Loading...</span>
|
| 67 |
+
</div>
|
| 68 |
+
<p class="mt-3 mb-0">正在分析风险,请稍候...</p>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<!-- 个股风险分析结果 -->
|
| 72 |
+
<div id="stock-risk-result" style="display: none;">
|
| 73 |
+
<div class="row g-3 mb-3">
|
| 74 |
+
<div class="col-md-6">
|
| 75 |
+
<div class="card h-100">
|
| 76 |
+
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
| 77 |
+
<h5 class="mb-0">风险概览</h5>
|
| 78 |
+
<span id="risk-level-badge" class="badge"></span>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="card-body">
|
| 81 |
+
<div class="row mb-3">
|
| 82 |
+
<div class="col-md-7">
|
| 83 |
+
<h3 id="stock-name" class="mb-0 fs-4"></h3>
|
| 84 |
+
<p id="stock-info" class="text-muted mb-0 small"></p>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="col-md-5 text-end">
|
| 87 |
+
<div id="risk-gauge-chart" style="height: 120px;"></div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="row">
|
| 91 |
+
<div class="col-12">
|
| 92 |
+
<h6>风险预警</h6>
|
| 93 |
+
<div id="risk-alerts" class="mt-2">
|
| 94 |
+
<!-- 风险预警内容将在JS中动态填充 -->
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="col-md-6">
|
| 102 |
+
<div class="card h-100">
|
| 103 |
+
<div class="card-header py-2">
|
| 104 |
+
<h5 class="mb-0">风险构成</h5>
|
| 105 |
+
</div>
|
| 106 |
+
<div class="card-body">
|
| 107 |
+
<div id="risk-radar-chart" style="height: 220px;"></div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="row g-3 mb-3">
|
| 114 |
+
<div class="col-md-6">
|
| 115 |
+
<div class="card h-100">
|
| 116 |
+
<div class="card-header py-2">
|
| 117 |
+
<h5 class="mb-0">波动率风险</h5>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="card-body">
|
| 120 |
+
<div class="row">
|
| 121 |
+
<div class="col-md-6">
|
| 122 |
+
<h6>波动率指标</h6>
|
| 123 |
+
<p><span class="text-muted">当前波动率:</span> <span id="current-volatility" class="fw-bold"></span></p>
|
| 124 |
+
<p><span class="text-muted">变化率:</span> <span id="volatility-change" class="fw-bold"></span></p>
|
| 125 |
+
<p><span class="text-muted">风险等级:</span> <span id="volatility-risk-level" class="fw-bold"></span></p>
|
| 126 |
+
<p class="small text-muted" id="volatility-comment"></p>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="col-md-6">
|
| 129 |
+
<div id="volatility-chart" style="height: 150px;"></div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
<div class="col-md-6">
|
| 136 |
+
<div class="card h-100">
|
| 137 |
+
<div class="card-header py-2">
|
| 138 |
+
<h5 class="mb-0">趋势风险</h5>
|
| 139 |
+
</div>
|
| 140 |
+
<div class="card-body">
|
| 141 |
+
<div class="row">
|
| 142 |
+
<div class="col-md-6">
|
| 143 |
+
<h6>趋势指标</h6>
|
| 144 |
+
<p><span class="text-muted">当前趋势:</span> <span id="current-trend" class="fw-bold"></span></p>
|
| 145 |
+
<p><span class="text-muted">均线关系:</span> <span id="ma-relationship" class="fw-bold"></span></p>
|
| 146 |
+
<p><span class="text-muted">风险等级:</span> <span id="trend-risk-level" class="fw-bold"></span></p>
|
| 147 |
+
<p class="small text-muted" id="trend-comment"></p>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="col-md-6">
|
| 150 |
+
<div id="trend-chart" style="height: 150px;"></div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div class="row g-3 mb-3">
|
| 159 |
+
<div class="col-md-6">
|
| 160 |
+
<div class="card h-100">
|
| 161 |
+
<div class="card-header py-2">
|
| 162 |
+
<h5 class="mb-0">反转风险</h5>
|
| 163 |
+
</div>
|
| 164 |
+
<div class="card-body">
|
| 165 |
+
<div class="row">
|
| 166 |
+
<div class="col-md-6">
|
| 167 |
+
<h6>反转信号</h6>
|
| 168 |
+
<p><span class="text-muted">反转信号数:</span> <span id="reversal-signals" class="fw-bold"></span></p>
|
| 169 |
+
<p><span class="text-muted">可能方向:</span> <span id="reversal-direction" class="fw-bold"></span></p>
|
| 170 |
+
<p><span class="text-muted">风险等级:</span> <span id="reversal-risk-level" class="fw-bold"></span></p>
|
| 171 |
+
<p class="small text-muted" id="reversal-comment"></p>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="col-md-6">
|
| 174 |
+
<div id="reversal-chart" style="height: 150px;"></div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="col-md-6">
|
| 181 |
+
<div class="card h-100">
|
| 182 |
+
<div class="card-header py-2">
|
| 183 |
+
<h5 class="mb-0">成交量风险</h5>
|
| 184 |
+
</div>
|
| 185 |
+
<div class="card-body">
|
| 186 |
+
<div class="row">
|
| 187 |
+
<div class="col-md-6">
|
| 188 |
+
<h6>成交量指标</h6>
|
| 189 |
+
<p><span class="text-muted">成交量比率:</span> <span id="volume-ratio" class="fw-bold"></span></p>
|
| 190 |
+
<p><span class="text-muted">成交量模式:</span> <span id="volume-pattern" class="fw-bold"></span></p>
|
| 191 |
+
<p><span class="text-muted">风险等级:</span> <span id="volume-risk-level" class="fw-bold"></span></p>
|
| 192 |
+
<p class="small text-muted" id="volume-comment"></p>
|
| 193 |
+
</div>
|
| 194 |
+
<div class="col-md-6">
|
| 195 |
+
<div id="volume-chart" style="height: 150px;"></div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<!-- 投资组合风险分析结果 -->
|
| 205 |
+
<div id="portfolio-risk-result" style="display: none;">
|
| 206 |
+
<div class="row g-3 mb-3">
|
| 207 |
+
<div class="col-md-6">
|
| 208 |
+
<div class="card h-100">
|
| 209 |
+
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
| 210 |
+
<h5 class="mb-0">组合风险概览</h5>
|
| 211 |
+
<span id="portfolio-risk-level-badge" class="badge"></span>
|
| 212 |
+
</div>
|
| 213 |
+
<div class="card-body">
|
| 214 |
+
<div class="row mb-3">
|
| 215 |
+
<div class="col-md-7">
|
| 216 |
+
<h3 class="mb-0 fs-4">我的投资组合</h3>
|
| 217 |
+
<p class="text-muted mb-0 small">包含 <span id="portfolio-stock-count">0</span> 只股票</p>
|
| 218 |
+
</div>
|
| 219 |
+
<div class="col-md-5 text-end">
|
| 220 |
+
<div id="portfolio-risk-gauge-chart" style="height: 120px;"></div>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
<div class="row">
|
| 224 |
+
<div class="col-12">
|
| 225 |
+
<h6>风险集中度</h6>
|
| 226 |
+
<p><span class="text-muted">行业集中度:</span> <span id="industry-concentration" class="fw-bold"></span></p>
|
| 227 |
+
<p><span class="text-muted">高风险股票占比:</span> <span id="high-risk-concentration" class="fw-bold"></span></p>
|
| 228 |
+
<div id="portfolio-risk-alerts" class="mt-2">
|
| 229 |
+
<!-- 风险预警内容将在JS中动态填充 -->
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
<div class="col-md-6">
|
| 237 |
+
<div class="card h-100">
|
| 238 |
+
<div class="card-header py-2">
|
| 239 |
+
<h5 class="mb-0">行业分布</h5>
|
| 240 |
+
</div>
|
| 241 |
+
<div class="card-body">
|
| 242 |
+
<div id="industry-distribution-chart" style="height: 220px;"></div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
<div class="row g-3 mb-3">
|
| 249 |
+
<div class="col-12">
|
| 250 |
+
<div class="card">
|
| 251 |
+
<div class="card-header py-2">
|
| 252 |
+
<h5 class="mb-0">高风险股票</h5>
|
| 253 |
+
</div>
|
| 254 |
+
<div class="card-body p-0">
|
| 255 |
+
<div class="table-responsive">
|
| 256 |
+
<table class="table table-sm table-striped table-hover mb-0">
|
| 257 |
+
<thead>
|
| 258 |
+
<tr>
|
| 259 |
+
<th>代码</th>
|
| 260 |
+
<th>名称</th>
|
| 261 |
+
<th>风险评分</th>
|
| 262 |
+
<th>风险等级</th>
|
| 263 |
+
<th>权重</th>
|
| 264 |
+
<th>主要风险</th>
|
| 265 |
+
<th>操作</th>
|
| 266 |
+
</tr>
|
| 267 |
+
</thead>
|
| 268 |
+
<tbody id="high-risk-stocks-table">
|
| 269 |
+
<!-- 高风险股票列表将在JS中动态填充 -->
|
| 270 |
+
</tbody>
|
| 271 |
+
</table>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div class="row g-3 mb-3">
|
| 279 |
+
<div class="col-12">
|
| 280 |
+
<div class="card">
|
| 281 |
+
<div class="card-header py-2">
|
| 282 |
+
<h5 class="mb-0">风险预警列表</h5>
|
| 283 |
+
</div>
|
| 284 |
+
<div class="card-body p-0">
|
| 285 |
+
<div class="table-responsive">
|
| 286 |
+
<table class="table table-sm table-striped table-hover mb-0">
|
| 287 |
+
<thead>
|
| 288 |
+
<tr>
|
| 289 |
+
<th>代码</th>
|
| 290 |
+
<th>名称</th>
|
| 291 |
+
<th>预警类型</th>
|
| 292 |
+
<th>风险等级</th>
|
| 293 |
+
<th>预警信息</th>
|
| 294 |
+
</tr>
|
| 295 |
+
</thead>
|
| 296 |
+
<tbody id="risk-alerts-table">
|
| 297 |
+
<!-- 风险预警列表将在JS中动态填充 -->
|
| 298 |
+
</tbody>
|
| 299 |
+
</table>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
{% endblock %}
|
| 308 |
+
|
| 309 |
+
{% block scripts %}
|
| 310 |
+
<script>
|
| 311 |
+
$(document).ready(function() {
|
| 312 |
+
// 个股风险分析表单提交
|
| 313 |
+
$('#stock-risk-form').submit(function(e) {
|
| 314 |
+
e.preventDefault();
|
| 315 |
+
const stockCode = $('#stock-code').val().trim();
|
| 316 |
+
const marketType = $('#market-type').val();
|
| 317 |
+
|
| 318 |
+
if (!stockCode) {
|
| 319 |
+
showError('请输入股票代码!');
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
analyzeStockRisk(stockCode, marketType);
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
// 分析投资组合风险按钮点击
|
| 327 |
+
$('#analyze-portfolio-btn').click(function() {
|
| 328 |
+
analyzePortfolioRisk();
|
| 329 |
+
});
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
function analyzeStockRisk(stockCode, marketType) {
|
| 333 |
+
$('#loading-panel').show();
|
| 334 |
+
$('#stock-risk-result').hide();
|
| 335 |
+
$('#portfolio-risk-result').hide();
|
| 336 |
+
|
| 337 |
+
$.ajax({
|
| 338 |
+
url: '/api/risk_analysis',
|
| 339 |
+
type: 'POST',
|
| 340 |
+
contentType: 'application/json',
|
| 341 |
+
data: JSON.stringify({
|
| 342 |
+
stock_code: stockCode,
|
| 343 |
+
market_type: marketType
|
| 344 |
+
}),
|
| 345 |
+
success: function(response) {
|
| 346 |
+
$('#loading-panel').hide();
|
| 347 |
+
renderStockRiskAnalysis(response, stockCode);
|
| 348 |
+
$('#stock-risk-result').show();
|
| 349 |
+
},
|
| 350 |
+
error: function(xhr, status, error) {
|
| 351 |
+
$('#loading-panel').hide();
|
| 352 |
+
let errorMsg = '获取风险分析数据失败';
|
| 353 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 354 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 355 |
+
} else if (error) {
|
| 356 |
+
errorMsg += ': ' + error;
|
| 357 |
+
}
|
| 358 |
+
showError(errorMsg);
|
| 359 |
+
}
|
| 360 |
+
});
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function analyzePortfolioRisk() {
|
| 364 |
+
// 尝试从本地存储获取投资组合数据
|
| 365 |
+
const savedPortfolio = localStorage.getItem('portfolio');
|
| 366 |
+
if (!savedPortfolio) {
|
| 367 |
+
showError('您的投资组合为空,请先添加股票到投资组合');
|
| 368 |
+
return;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
const portfolio = JSON.parse(savedPortfolio);
|
| 372 |
+
if (portfolio.length === 0) {
|
| 373 |
+
showError('您的投资组合为空,请先添加股票到投资组合');
|
| 374 |
+
return;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
$('#loading-panel').show();
|
| 378 |
+
$('#stock-risk-result').hide();
|
| 379 |
+
$('#portfolio-risk-result').hide();
|
| 380 |
+
|
| 381 |
+
$.ajax({
|
| 382 |
+
url: '/api/portfolio_risk',
|
| 383 |
+
type: 'POST',
|
| 384 |
+
contentType: 'application/json',
|
| 385 |
+
data: JSON.stringify({
|
| 386 |
+
portfolio: portfolio
|
| 387 |
+
}),
|
| 388 |
+
success: function(response) {
|
| 389 |
+
$('#loading-panel').hide();
|
| 390 |
+
renderPortfolioRiskAnalysis(response, portfolio);
|
| 391 |
+
$('#portfolio-risk-result').show();
|
| 392 |
+
},
|
| 393 |
+
error: function(xhr, status, error) {
|
| 394 |
+
$('#loading-panel').hide();
|
| 395 |
+
let errorMsg = '获取投资组合风险分析数据失败';
|
| 396 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 397 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 398 |
+
} else if (error) {
|
| 399 |
+
errorMsg += ': ' + error;
|
| 400 |
+
}
|
| 401 |
+
showError(errorMsg);
|
| 402 |
+
}
|
| 403 |
+
});
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function renderStockRiskAnalysis(data, stockCode) {
|
| 407 |
+
// 设置基本信息
|
| 408 |
+
$('#stock-name').text(data.stock_name || stockCode);
|
| 409 |
+
$('#stock-info').text(data.industry || '未知行业');
|
| 410 |
+
|
| 411 |
+
// 设置风险等级
|
| 412 |
+
const riskScore = data.total_risk_score || 0;
|
| 413 |
+
const riskLevel = data.risk_level || '未知';
|
| 414 |
+
const riskLevelBadgeClass = getRiskLevelBadgeClass(riskLevel);
|
| 415 |
+
$('#risk-level-badge').text(riskLevel).removeClass().addClass(`badge ${riskLevelBadgeClass}`);
|
| 416 |
+
|
| 417 |
+
// 设置风险预警
|
| 418 |
+
renderRiskAlerts(data.alerts || []);
|
| 419 |
+
|
| 420 |
+
// 渲染风险仪表图
|
| 421 |
+
renderRiskGaugeChart(riskScore);
|
| 422 |
+
|
| 423 |
+
// 渲染风险雷达图
|
| 424 |
+
renderRiskRadarChart(data);
|
| 425 |
+
|
| 426 |
+
// 设置波动率风险
|
| 427 |
+
const volatilityRisk = data.volatility_risk || {};
|
| 428 |
+
$('#current-volatility').text(formatPercent(volatilityRisk.value || 0, 2));
|
| 429 |
+
|
| 430 |
+
const volatilityChange = volatilityRisk.change || 0;
|
| 431 |
+
const volatilityChangeClass = volatilityChange >= 0 ? 'trend-up' : 'trend-down';
|
| 432 |
+
$('#volatility-change').text(formatPercent(volatilityChange, 2)).addClass(volatilityChangeClass);
|
| 433 |
+
|
| 434 |
+
$('#volatility-risk-level').text(volatilityRisk.risk_level || '未知');
|
| 435 |
+
$('#volatility-comment').text('波动率反映价格波动的剧烈程度,高波动率意味着高风险');
|
| 436 |
+
|
| 437 |
+
// 设置趋势风险
|
| 438 |
+
const trendRisk = data.trend_risk || {};
|
| 439 |
+
$('#current-trend').text(trendRisk.trend || '未知');
|
| 440 |
+
$('#ma-relationship').text(trendRisk.ma_relationship || '未知');
|
| 441 |
+
$('#trend-risk-level').text(trendRisk.risk_level || '未知');
|
| 442 |
+
$('#trend-comment').text('下降趋势中的股票有更高的风险,特别是在空头排列时');
|
| 443 |
+
|
| 444 |
+
// 设置反转风险
|
| 445 |
+
const reversalRisk = data.reversal_risk || {};
|
| 446 |
+
$('#reversal-signals').text(reversalRisk.reversal_signals || 0);
|
| 447 |
+
$('#reversal-direction').text(reversalRisk.direction || '未知');
|
| 448 |
+
$('#reversal-risk-level').text(reversalRisk.risk_level || '未知');
|
| 449 |
+
$('#reversal-comment').text('多个技术指标同时出现反转信号,意味着趋势可能即将改变');
|
| 450 |
+
|
| 451 |
+
// 设置成交量风险
|
| 452 |
+
const volumeRisk = data.volume_risk || {};
|
| 453 |
+
$('#volume-ratio').text(formatNumber(volumeRisk.volume_ratio || 0, 2));
|
| 454 |
+
$('#volume-pattern').text(volumeRisk.pattern || '未知');
|
| 455 |
+
$('#volume-risk-level').text(volumeRisk.risk_level || '未知');
|
| 456 |
+
$('#volume-comment').text('成交量异常变化常常预示价格波动,尤其是量价背离时');
|
| 457 |
+
|
| 458 |
+
// 渲染各个风险维度的图表
|
| 459 |
+
renderVolatilityChart([5.2, 3.8, 4.5, 7.2, 6.3]);
|
| 460 |
+
renderTrendChart([110, 108, 106, 102, 98]);
|
| 461 |
+
renderReversalChart([55, 60, 65, 72, 68]);
|
| 462 |
+
renderVolumeChart([1.2, 0.8, 1.5, 2.8, 2.1]);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
function renderPortfolioRiskAnalysis(data, portfolio) {
|
| 466 |
+
// 设置基本信息
|
| 467 |
+
$('#portfolio-stock-count').text(portfolio.length);
|
| 468 |
+
|
| 469 |
+
// 设置风险等级
|
| 470 |
+
const riskScore = data.portfolio_risk_score || 0;
|
| 471 |
+
const riskLevel = data.risk_level || '未知';
|
| 472 |
+
const riskLevelBadgeClass = getRiskLevelBadgeClass(riskLevel);
|
| 473 |
+
$('#portfolio-risk-level-badge').text(riskLevel).removeClass().addClass(`badge ${riskLevelBadgeClass}`);
|
| 474 |
+
|
| 475 |
+
// 设置风险集中度
|
| 476 |
+
const riskConcentration = data.risk_concentration || {};
|
| 477 |
+
$('#industry-concentration').text(`${riskConcentration.max_industry || '未知'} (${formatPercent(riskConcentration.max_industry_weight || 0, 1)})`);
|
| 478 |
+
$('#high-risk-concentration').text(formatPercent(riskConcentration.high_risk_weight || 0, 1));
|
| 479 |
+
|
| 480 |
+
// 设置风险预警
|
| 481 |
+
renderPortfolioRiskAlerts(data.alerts || []);
|
| 482 |
+
|
| 483 |
+
// 渲染投资组合风险仪表图
|
| 484 |
+
renderPortfolioRiskGaugeChart(riskScore);
|
| 485 |
+
|
| 486 |
+
// 渲染行业分布图
|
| 487 |
+
renderIndustryDistributionChart(portfolio);
|
| 488 |
+
|
| 489 |
+
// 渲染高风险股票列表
|
| 490 |
+
renderHighRiskStocksTable(data.high_risk_stocks || []);
|
| 491 |
+
|
| 492 |
+
// 渲染风险预警列表
|
| 493 |
+
renderRiskAlertsTable(data.alerts || []);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
function renderRiskAlerts(alerts) {
|
| 497 |
+
let html = '';
|
| 498 |
+
|
| 499 |
+
if (alerts.length === 0) {
|
| 500 |
+
html = '<div class="alert alert-success">未发现显著风险因素</div>';
|
| 501 |
+
} else {
|
| 502 |
+
alerts.forEach(alert => {
|
| 503 |
+
const alertClass = getRiskAlertClass(alert.level);
|
| 504 |
+
html += `
|
| 505 |
+
<div class="alert ${alertClass} mb-2">
|
| 506 |
+
<strong>${alert.type}风险:</strong> ${alert.message}
|
| 507 |
+
</div>
|
| 508 |
+
`;
|
| 509 |
+
});
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
$('#risk-alerts').html(html);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function renderPortfolioRiskAlerts(alerts) {
|
| 516 |
+
let html = '';
|
| 517 |
+
|
| 518 |
+
if (alerts.length === 0) {
|
| 519 |
+
html = '<div class="alert alert-success">投资组合风险均衡,未发现显著风险集中</div>';
|
| 520 |
+
} else {
|
| 521 |
+
let alertCount = 0;
|
| 522 |
+
alerts.forEach(alert => {
|
| 523 |
+
if (alertCount < 3) { // 只显示前3条
|
| 524 |
+
const alertClass = getRiskAlertClass(alert.level);
|
| 525 |
+
html += `
|
| 526 |
+
<div class="alert ${alertClass} mb-2">
|
| 527 |
+
<strong>${alert.stock_code}:</strong> ${alert.message}
|
| 528 |
+
</div>
|
| 529 |
+
`;
|
| 530 |
+
alertCount++;
|
| 531 |
+
}
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
if (alerts.length > 3) {
|
| 535 |
+
html += `<p class="text-muted small">还有 ${alerts.length - 3} 条风险预警,请查看下方详情表格</p>`;
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
$('#portfolio-risk-alerts').html(html);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
function renderRiskGaugeChart(score) {
|
| 543 |
+
const options = {
|
| 544 |
+
series: [score],
|
| 545 |
+
chart: {
|
| 546 |
+
height: 120,
|
| 547 |
+
type: 'radialBar',
|
| 548 |
+
},
|
| 549 |
+
plotOptions: {
|
| 550 |
+
radialBar: {
|
| 551 |
+
hollow: {
|
| 552 |
+
size: '70%',
|
| 553 |
+
},
|
| 554 |
+
dataLabels: {
|
| 555 |
+
show: true,
|
| 556 |
+
name: {
|
| 557 |
+
show: false
|
| 558 |
+
},
|
| 559 |
+
value: {
|
| 560 |
+
fontSize: '16px',
|
| 561 |
+
fontWeight: 'bold',
|
| 562 |
+
offsetY: 5
|
| 563 |
+
}
|
| 564 |
+
},
|
| 565 |
+
track: {
|
| 566 |
+
background: '#f2f2f2'
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
},
|
| 570 |
+
fill: {
|
| 571 |
+
colors: [getRiskColor(score)]
|
| 572 |
+
},
|
| 573 |
+
labels: ['风险分数']
|
| 574 |
+
};
|
| 575 |
+
|
| 576 |
+
const chart = new ApexCharts(document.querySelector("#risk-gauge-chart"), options);
|
| 577 |
+
chart.render();
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
function renderPortfolioRiskGaugeChart(score) {
|
| 581 |
+
const options = {
|
| 582 |
+
series: [score],
|
| 583 |
+
chart: {
|
| 584 |
+
height: 120,
|
| 585 |
+
type: 'radialBar',
|
| 586 |
+
},
|
| 587 |
+
plotOptions: {
|
| 588 |
+
radialBar: {
|
| 589 |
+
hollow: {
|
| 590 |
+
size: '70%',
|
| 591 |
+
},
|
| 592 |
+
dataLabels: {
|
| 593 |
+
show: true,
|
| 594 |
+
name: {
|
| 595 |
+
show: false
|
| 596 |
+
},
|
| 597 |
+
value: {
|
| 598 |
+
fontSize: '16px',
|
| 599 |
+
fontWeight: 'bold',
|
| 600 |
+
offsetY: 5
|
| 601 |
+
}
|
| 602 |
+
},
|
| 603 |
+
track: {
|
| 604 |
+
background: '#f2f2f2'
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
},
|
| 608 |
+
fill: {
|
| 609 |
+
colors: [getRiskColor(score)]
|
| 610 |
+
},
|
| 611 |
+
labels: ['风险分数']
|
| 612 |
+
};
|
| 613 |
+
|
| 614 |
+
const chart = new ApexCharts(document.querySelector("#portfolio-risk-gauge-chart"), options);
|
| 615 |
+
chart.render();
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
function renderRiskRadarChart(data) {
|
| 619 |
+
const volatilityRisk = data.volatility_risk?.score || 0;
|
| 620 |
+
const trendRisk = data.trend_risk?.score || 0;
|
| 621 |
+
const reversalRisk = data.reversal_risk?.score || 0;
|
| 622 |
+
const volumeRisk = data.volume_risk?.score || 0;
|
| 623 |
+
|
| 624 |
+
const options = {
|
| 625 |
+
series: [{
|
| 626 |
+
name: '风险评分',
|
| 627 |
+
data: [volatilityRisk, trendRisk, reversalRisk, volumeRisk]
|
| 628 |
+
}],
|
| 629 |
+
chart: {
|
| 630 |
+
height: 220,
|
| 631 |
+
type: 'radar',
|
| 632 |
+
toolbar: {
|
| 633 |
+
show: false
|
| 634 |
+
}
|
| 635 |
+
},
|
| 636 |
+
xaxis: {
|
| 637 |
+
categories: ['波动率风险', '趋势风险', '反转风险', '成交量风险']
|
| 638 |
+
},
|
| 639 |
+
fill: {
|
| 640 |
+
opacity: 0.7,
|
| 641 |
+
colors: ['#dc3545']
|
| 642 |
+
},
|
| 643 |
+
markers: {
|
| 644 |
+
size: 4
|
| 645 |
+
},
|
| 646 |
+
title: {
|
| 647 |
+
text: '风险雷达图',
|
| 648 |
+
align: 'center',
|
| 649 |
+
style: {
|
| 650 |
+
fontSize: '14px'
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
};
|
| 654 |
+
|
| 655 |
+
const chart = new ApexCharts(document.querySelector("#risk-radar-chart"), options);
|
| 656 |
+
chart.render();
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
function renderIndustryDistributionChart(portfolio) {
|
| 660 |
+
// 根据投资组合计算行业分布
|
| 661 |
+
const industries = {};
|
| 662 |
+
let totalWeight = 0;
|
| 663 |
+
|
| 664 |
+
portfolio.forEach(stock => {
|
| 665 |
+
const industry = stock.industry || '未知';
|
| 666 |
+
const weight = stock.weight || 1;
|
| 667 |
+
|
| 668 |
+
if (industries[industry]) {
|
| 669 |
+
industries[industry] += weight;
|
| 670 |
+
} else {
|
| 671 |
+
industries[industry] = weight;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
totalWeight += weight;
|
| 675 |
+
});
|
| 676 |
+
|
| 677 |
+
const series = [];
|
| 678 |
+
const labels = [];
|
| 679 |
+
|
| 680 |
+
for (const industry in industries) {
|
| 681 |
+
if (industries.hasOwnProperty(industry)) {
|
| 682 |
+
series.push(industries[industry]);
|
| 683 |
+
labels.push(industry);
|
| 684 |
+
}
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
const options = {
|
| 688 |
+
series: series,
|
| 689 |
+
chart: {
|
| 690 |
+
height: 220,
|
| 691 |
+
type: 'pie',
|
| 692 |
+
},
|
| 693 |
+
labels: labels,
|
| 694 |
+
colors: ['#4e73df', '#1cc88a', '#36b9cc', '#f6c23e', '#e74a3b', '#858796', '#5a5c69', '#6f42c1'],
|
| 695 |
+
legend: {
|
| 696 |
+
position: 'bottom'
|
| 697 |
+
},
|
| 698 |
+
tooltip: {
|
| 699 |
+
y: {
|
| 700 |
+
formatter: function(value) {
|
| 701 |
+
return value + ' (' + ((value / totalWeight) * 100).toFixed(1) + '%)';
|
| 702 |
+
}
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
};
|
| 706 |
+
|
| 707 |
+
const chart = new ApexCharts(document.querySelector("#industry-distribution-chart"), options);
|
| 708 |
+
chart.render();
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
function renderVolatilityChart(data) {
|
| 712 |
+
const options = {
|
| 713 |
+
series: [{
|
| 714 |
+
name: '波动率(%)',
|
| 715 |
+
data: data
|
| 716 |
+
}],
|
| 717 |
+
chart: {
|
| 718 |
+
height: 150,
|
| 719 |
+
type: 'line',
|
| 720 |
+
toolbar: {
|
| 721 |
+
show: false
|
| 722 |
+
}
|
| 723 |
+
},
|
| 724 |
+
stroke: {
|
| 725 |
+
curve: 'smooth',
|
| 726 |
+
width: 3
|
| 727 |
+
},
|
| 728 |
+
xaxis: {
|
| 729 |
+
labels: {
|
| 730 |
+
show: false
|
| 731 |
+
}
|
| 732 |
+
},
|
| 733 |
+
yaxis: {
|
| 734 |
+
labels: {
|
| 735 |
+
formatter: function(val) {
|
| 736 |
+
return val.toFixed(1) + '%';
|
| 737 |
+
},
|
| 738 |
+
style: {
|
| 739 |
+
fontSize: '10px'
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
},
|
| 743 |
+
colors: ['#dc3545'],
|
| 744 |
+
tooltip: {
|
| 745 |
+
y: {
|
| 746 |
+
formatter: function(value) {
|
| 747 |
+
return value.toFixed(2) + '%';
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
},
|
| 751 |
+
markers: {
|
| 752 |
+
size: 3
|
| 753 |
+
}
|
| 754 |
+
};
|
| 755 |
+
|
| 756 |
+
const chart = new ApexCharts(document.querySelector("#volatility-chart"), options);
|
| 757 |
+
chart.render();
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
function renderTrendChart(data) {
|
| 761 |
+
const options = {
|
| 762 |
+
series: [{
|
| 763 |
+
name: '价格',
|
| 764 |
+
data: data
|
| 765 |
+
}, {
|
| 766 |
+
name: 'MA20',
|
| 767 |
+
data: [109, 107, 105, 103, 101]
|
| 768 |
+
}],
|
| 769 |
+
chart: {
|
| 770 |
+
height: 150,
|
| 771 |
+
type: 'line',
|
| 772 |
+
toolbar: {
|
| 773 |
+
show: false
|
| 774 |
+
}
|
| 775 |
+
},
|
| 776 |
+
stroke: {
|
| 777 |
+
curve: 'straight',
|
| 778 |
+
width: [3, 2]
|
| 779 |
+
},
|
| 780 |
+
xaxis: {
|
| 781 |
+
labels: {
|
| 782 |
+
show: false
|
| 783 |
+
}
|
| 784 |
+
},
|
| 785 |
+
yaxis: {
|
| 786 |
+
labels: {
|
| 787 |
+
formatter: function(val) {
|
| 788 |
+
return val.toFixed(0);
|
| 789 |
+
},
|
| 790 |
+
style: {
|
| 791 |
+
fontSize: '10px'
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
},
|
| 795 |
+
colors: ['#dc3545', '#007bff'],
|
| 796 |
+
tooltip: {
|
| 797 |
+
y: {
|
| 798 |
+
formatter: function(value) {
|
| 799 |
+
return value.toFixed(2);
|
| 800 |
+
}
|
| 801 |
+
}
|
| 802 |
+
},
|
| 803 |
+
markers: {
|
| 804 |
+
size: 3
|
| 805 |
+
},
|
| 806 |
+
legend: {
|
| 807 |
+
show: false
|
| 808 |
+
}
|
| 809 |
+
};
|
| 810 |
+
|
| 811 |
+
const chart = new ApexCharts(document.querySelector("#trend-chart"), options);
|
| 812 |
+
chart.render();
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
function renderReversalChart(data) {
|
| 816 |
+
const options = {
|
| 817 |
+
series: [{
|
| 818 |
+
name: 'RSI',
|
| 819 |
+
data: data
|
| 820 |
+
}],
|
| 821 |
+
chart: {
|
| 822 |
+
height: 150,
|
| 823 |
+
type: 'line',
|
| 824 |
+
toolbar: {
|
| 825 |
+
show: false
|
| 826 |
+
}
|
| 827 |
+
},
|
| 828 |
+
stroke: {
|
| 829 |
+
curve: 'smooth',
|
| 830 |
+
width: 3
|
| 831 |
+
},
|
| 832 |
+
xaxis: {
|
| 833 |
+
labels: {
|
| 834 |
+
show: false
|
| 835 |
+
}
|
| 836 |
+
},
|
| 837 |
+
yaxis: {
|
| 838 |
+
min: 0,
|
| 839 |
+
max: 100,
|
| 840 |
+
labels: {
|
| 841 |
+
formatter: function(val) {
|
| 842 |
+
return val.toFixed(0);
|
| 843 |
+
},
|
| 844 |
+
style: {
|
| 845 |
+
fontSize: '10px'
|
| 846 |
+
}
|
| 847 |
+
}
|
| 848 |
+
},
|
| 849 |
+
colors: ['#ffc107'],
|
| 850 |
+
tooltip: {
|
| 851 |
+
y: {
|
| 852 |
+
formatter: function(value) {
|
| 853 |
+
return value.toFixed(2);
|
| 854 |
+
}
|
| 855 |
+
}
|
| 856 |
+
},
|
| 857 |
+
markers: {
|
| 858 |
+
size: 3
|
| 859 |
+
},
|
| 860 |
+
annotations: {
|
| 861 |
+
yaxis: [{
|
| 862 |
+
y: 70,
|
| 863 |
+
borderColor: '#dc3545',
|
| 864 |
+
label: {
|
| 865 |
+
text: '超买',
|
| 866 |
+
style: {
|
| 867 |
+
color: '#fff',
|
| 868 |
+
background: '#dc3545'
|
| 869 |
+
}
|
| 870 |
+
}
|
| 871 |
+
}, {
|
| 872 |
+
y: 30,
|
| 873 |
+
borderColor: '#28a745',
|
| 874 |
+
label: {
|
| 875 |
+
text: '超卖',
|
| 876 |
+
style: {
|
| 877 |
+
color: '#fff',
|
| 878 |
+
background: '#28a745'
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
}]
|
| 882 |
+
}
|
| 883 |
+
};
|
| 884 |
+
|
| 885 |
+
const chart = new ApexCharts(document.querySelector("#reversal-chart"), options);
|
| 886 |
+
chart.render();
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
function renderVolumeChart(data) {
|
| 890 |
+
const options = {
|
| 891 |
+
series: [{
|
| 892 |
+
name: '成交量比率',
|
| 893 |
+
data: data
|
| 894 |
+
}],
|
| 895 |
+
chart: {
|
| 896 |
+
height: 150,
|
| 897 |
+
type: 'bar',
|
| 898 |
+
toolbar: {
|
| 899 |
+
show: false
|
| 900 |
+
}
|
| 901 |
+
},
|
| 902 |
+
xaxis: {
|
| 903 |
+
labels: {
|
| 904 |
+
show: false
|
| 905 |
+
}
|
| 906 |
+
},
|
| 907 |
+
yaxis: {
|
| 908 |
+
labels: {
|
| 909 |
+
formatter: function(val) {
|
| 910 |
+
return val.toFixed(1) + 'x';
|
| 911 |
+
},
|
| 912 |
+
style: {
|
| 913 |
+
fontSize: '10px'
|
| 914 |
+
}
|
| 915 |
+
}
|
| 916 |
+
},
|
| 917 |
+
colors: ['#4e73df'],
|
| 918 |
+
tooltip: {
|
| 919 |
+
y: {
|
| 920 |
+
formatter: function(value) {
|
| 921 |
+
return value.toFixed(2) + 'x';
|
| 922 |
+
}
|
| 923 |
+
}
|
| 924 |
+
},
|
| 925 |
+
plotOptions: {
|
| 926 |
+
bar: {
|
| 927 |
+
columnWidth: '50%'
|
| 928 |
+
}
|
| 929 |
+
},
|
| 930 |
+
dataLabels: {
|
| 931 |
+
enabled: false
|
| 932 |
+
}
|
| 933 |
+
};
|
| 934 |
+
|
| 935 |
+
const chart = new ApexCharts(document.querySelector("#volume-chart"), options);
|
| 936 |
+
chart.render();
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
function renderHighRiskStocksTable(highRiskStocks) {
|
| 940 |
+
let html = '';
|
| 941 |
+
|
| 942 |
+
if (highRiskStocks.length === 0) {
|
| 943 |
+
html = '<tr><td colspan="7" class="text-center">未发现高风险股票</td></tr>';
|
| 944 |
+
} else {
|
| 945 |
+
highRiskStocks.forEach(stock => {
|
| 946 |
+
const riskScoreClass = getRiskScoreClass(stock.risk_score);
|
| 947 |
+
const riskLevelBadgeClass = getRiskLevelBadgeClass(stock.risk_level);
|
| 948 |
+
|
| 949 |
+
html += `
|
| 950 |
+
<tr>
|
| 951 |
+
<td>${stock.stock_code}</td>
|
| 952 |
+
<td>${stock.stock_name || '未知'}</td>
|
| 953 |
+
<td><span class="badge ${riskScoreClass}">${stock.risk_score}</span></td>
|
| 954 |
+
<td><span class="badge ${riskLevelBadgeClass}">${stock.risk_level}</span></td>
|
| 955 |
+
<td>${formatPercent(stock.weight || 0, 1)}</td>
|
| 956 |
+
<td>${stock.main_risk || '未知'}</td>
|
| 957 |
+
<td>
|
| 958 |
+
<a href="/stock_detail/${stock.stock_code}" class="btn btn-sm btn-outline-info me-1">
|
| 959 |
+
<i class="fas fa-chart-line"></i>
|
| 960 |
+
</a>
|
| 961 |
+
<a href="/risk_monitor?stock=${stock.stock_code}" class="btn btn-sm btn-outline-danger">
|
| 962 |
+
<i class="fas fa-exclamation-triangle"></i>
|
| 963 |
+
</a>
|
| 964 |
+
</td>
|
| 965 |
+
</tr>
|
| 966 |
+
`;
|
| 967 |
+
});
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
$('#high-risk-stocks-table').html(html);
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
function renderRiskAlertsTable(alerts) {
|
| 974 |
+
let html = '';
|
| 975 |
+
|
| 976 |
+
if (alerts.length === 0) {
|
| 977 |
+
html = '<tr><td colspan="5" class="text-center">暂无风险预警</td></tr>';
|
| 978 |
+
} else {
|
| 979 |
+
alerts.forEach(alert => {
|
| 980 |
+
const riskLevelBadgeClass = getRiskLevelBadgeClass(alert.level);
|
| 981 |
+
|
| 982 |
+
html += `
|
| 983 |
+
<tr>
|
| 984 |
+
<td>${alert.stock_code}</td>
|
| 985 |
+
<td>${alert.stock_name || '未知'}</td>
|
| 986 |
+
<td>${alert.type}</td>
|
| 987 |
+
<td><span class="badge ${riskLevelBadgeClass}">${alert.level}</span></td>
|
| 988 |
+
<td>${alert.message}</td>
|
| 989 |
+
</tr>
|
| 990 |
+
`;
|
| 991 |
+
});
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
$('#risk-alerts-table').html(html);
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
function getRiskLevelBadgeClass(level) {
|
| 998 |
+
switch (level) {
|
| 999 |
+
case '极高':
|
| 1000 |
+
return 'bg-danger';
|
| 1001 |
+
case '高':
|
| 1002 |
+
return 'bg-warning text-dark';
|
| 1003 |
+
case '中等':
|
| 1004 |
+
return 'bg-info text-dark';
|
| 1005 |
+
case '低':
|
| 1006 |
+
return 'bg-success';
|
| 1007 |
+
case '极低':
|
| 1008 |
+
return 'bg-secondary';
|
| 1009 |
+
default:
|
| 1010 |
+
return 'bg-secondary';
|
| 1011 |
+
}
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
function getRiskAlertClass(level) {
|
| 1015 |
+
switch (level) {
|
| 1016 |
+
case '高':
|
| 1017 |
+
return 'alert-danger';
|
| 1018 |
+
case '中':
|
| 1019 |
+
return 'alert-warning';
|
| 1020 |
+
case '低':
|
| 1021 |
+
return 'alert-info';
|
| 1022 |
+
default:
|
| 1023 |
+
return 'alert-secondary';
|
| 1024 |
+
}
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
function getRiskScoreClass(score) {
|
| 1028 |
+
if (score >= 80) return 'bg-danger';
|
| 1029 |
+
if (score >= 60) return 'bg-warning text-dark';
|
| 1030 |
+
if (score >= 40) return 'bg-info text-dark';
|
| 1031 |
+
return 'bg-success';
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
function getRiskColor(score) {
|
| 1035 |
+
if (score >= 80) return '#dc3545'; // 红色
|
| 1036 |
+
if (score >= 60) return '#ffc107'; // 黄色
|
| 1037 |
+
if (score >= 40) return '#17a2b8'; // 蓝色
|
| 1038 |
+
return '#28a745'; // 绿色
|
| 1039 |
+
}
|
| 1040 |
+
</script>
|
| 1041 |
+
{% endblock %}
|
templates/scenario_predict.html
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}情景预测 - {{ stock_code }} - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-3">
|
| 7 |
+
<div id="alerts-container"></div>
|
| 8 |
+
|
| 9 |
+
<div class="row mb-3">
|
| 10 |
+
<div class="col-12">
|
| 11 |
+
<div class="card">
|
| 12 |
+
<div class="card-header py-2">
|
| 13 |
+
<h5 class="mb-0">多情景预测</h5>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="card-body py-2">
|
| 16 |
+
<form id="scenario-form" class="row g-2">
|
| 17 |
+
<div class="col-md-3">
|
| 18 |
+
<div class="input-group input-group-sm">
|
| 19 |
+
<span class="input-group-text">股票代码</span>
|
| 20 |
+
<input type="text" class="form-control" id="stock-code" placeholder="例如: 600519" required>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="col-md-3">
|
| 24 |
+
<div class="input-group input-group-sm">
|
| 25 |
+
<span class="input-group-text">市场</span>
|
| 26 |
+
<select class="form-select" id="market-type">
|
| 27 |
+
<option value="A" selected>A股</option>
|
| 28 |
+
<option value="HK">港股</option>
|
| 29 |
+
<option value="US">美股</option>
|
| 30 |
+
</select>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="col-md-3">
|
| 34 |
+
<div class="input-group input-group-sm">
|
| 35 |
+
<span class="input-group-text">预测天数</span>
|
| 36 |
+
<select class="form-select" id="days">
|
| 37 |
+
<option value="30">30天</option>
|
| 38 |
+
<option value="60" selected>60天</option>
|
| 39 |
+
<option value="90">90天</option>
|
| 40 |
+
<option value="180">180天</option>
|
| 41 |
+
</select>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="col-md-3">
|
| 45 |
+
<button type="submit" class="btn btn-primary btn-sm w-100">
|
| 46 |
+
<i class="fas fa-chart-line"></i> 预测
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
</form>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div id="loading-panel" class="text-center py-5" style="display: none;">
|
| 56 |
+
<div class="spinner-border text-primary" role="status">
|
| 57 |
+
<span class="visually-hidden">Loading...</span>
|
| 58 |
+
</div>
|
| 59 |
+
<p class="mt-3 mb-0">正在生成预测结果...</p>
|
| 60 |
+
<p class="text-muted small mt-2">
|
| 61 |
+
<i class="fas fa-info-circle"></i>
|
| 62 |
+
AI分析需要一些时间,请耐心等待
|
| 63 |
+
</p>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div id="scenario-result" style="display: none;">
|
| 67 |
+
<div class="row g-3 mb-3">
|
| 68 |
+
<div class="col-12">
|
| 69 |
+
<div class="card">
|
| 70 |
+
<div class="card-header py-2">
|
| 71 |
+
<h5 class="mb-0">价格预测图</h5>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="card-body p-0">
|
| 74 |
+
<div id="price-prediction-chart" style="height: 400px;"></div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div class="row g-3 mb-3">
|
| 81 |
+
<div class="col-md-4">
|
| 82 |
+
<div class="card h-100">
|
| 83 |
+
<div class="card-header py-2 bg-success text-white">
|
| 84 |
+
<h5 class="mb-0">乐观情景</h5>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="card-body">
|
| 87 |
+
<div class="d-flex justify-content-between mb-3">
|
| 88 |
+
<div>
|
| 89 |
+
<h6>目标价</h6>
|
| 90 |
+
<h3 id="optimistic-price" class="text-success">--</h3>
|
| 91 |
+
</div>
|
| 92 |
+
<div>
|
| 93 |
+
<h6>预期涨幅</h6>
|
| 94 |
+
<h3 id="optimistic-change" class="text-success">--</h3>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
<p id="optimistic-analysis" class="small"></p>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="col-md-4">
|
| 102 |
+
<div class="card h-100">
|
| 103 |
+
<div class="card-header py-2 bg-primary text-white">
|
| 104 |
+
<h5 class="mb-0">中性情景</h5>
|
| 105 |
+
</div>
|
| 106 |
+
<div class="card-body">
|
| 107 |
+
<div class="d-flex justify-content-between mb-3">
|
| 108 |
+
<div>
|
| 109 |
+
<h6>目标价</h6>
|
| 110 |
+
<h3 id="neutral-price" class="text-primary">--</h3>
|
| 111 |
+
</div>
|
| 112 |
+
<div>
|
| 113 |
+
<h6>预期涨幅</h6>
|
| 114 |
+
<h3 id="neutral-change" class="text-primary">--</h3>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
<p id="neutral-analysis" class="small"></p>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="col-md-4">
|
| 122 |
+
<div class="card h-100">
|
| 123 |
+
<div class="card-header py-2 bg-danger text-white">
|
| 124 |
+
<h5 class="mb-0">悲观情景</h5>
|
| 125 |
+
</div>
|
| 126 |
+
<div class="card-body">
|
| 127 |
+
<div class="d-flex justify-content-between mb-3">
|
| 128 |
+
<div>
|
| 129 |
+
<h6>目标价</h6>
|
| 130 |
+
<h3 id="pessimistic-price" class="text-danger">--</h3>
|
| 131 |
+
</div>
|
| 132 |
+
<div>
|
| 133 |
+
<h6>预期涨幅</h6>
|
| 134 |
+
<h3 id="pessimistic-change" class="text-danger">--</h3>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
<p id="pessimistic-analysis" class="small"></p>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<div class="row g-3 mb-3">
|
| 144 |
+
<div class="col-12">
|
| 145 |
+
<div class="card">
|
| 146 |
+
<div class="card-header py-2">
|
| 147 |
+
<h5 class="mb-0">风险与机会</h5>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="card-body">
|
| 150 |
+
<div class="row">
|
| 151 |
+
<div class="col-md-6">
|
| 152 |
+
<h6 class="text-danger"><i class="fas fa-exclamation-triangle"></i> 风险因素</h6>
|
| 153 |
+
<ul id="risk-factors" class="small"></ul>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="col-md-6">
|
| 156 |
+
<h6 class="text-success"><i class="fas fa-lightbulb"></i> 有利因素</h6>
|
| 157 |
+
<ul id="opportunity-factors" class="small"></ul>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
{% endblock %}
|
| 167 |
+
|
| 168 |
+
{% block scripts %}
|
| 169 |
+
<script>
|
| 170 |
+
$(document).ready(function() {
|
| 171 |
+
$('#scenario-form').submit(function(e) {
|
| 172 |
+
e.preventDefault();
|
| 173 |
+
const stockCode = $('#stock-code').val().trim();
|
| 174 |
+
const marketType = $('#market-type').val();
|
| 175 |
+
const days = $('#days').val();
|
| 176 |
+
|
| 177 |
+
if (!stockCode) {
|
| 178 |
+
showError('请输入股票代码!');
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
fetchScenarioPrediction(stockCode, marketType, days);
|
| 183 |
+
});
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
function fetchScenarioPrediction(stockCode, marketType, days) {
|
| 187 |
+
$('#loading-panel').show();
|
| 188 |
+
$('#scenario-result').hide();
|
| 189 |
+
|
| 190 |
+
$.ajax({
|
| 191 |
+
url: '/api/scenario_predict',
|
| 192 |
+
type: 'POST',
|
| 193 |
+
contentType: 'application/json',
|
| 194 |
+
data: JSON.stringify({
|
| 195 |
+
stock_code: stockCode,
|
| 196 |
+
market_type: marketType,
|
| 197 |
+
days: parseInt(days)
|
| 198 |
+
}),
|
| 199 |
+
success: function(response) {
|
| 200 |
+
$('#loading-panel').hide();
|
| 201 |
+
renderScenarioPrediction(response, stockCode);
|
| 202 |
+
$('#scenario-result').show();
|
| 203 |
+
},
|
| 204 |
+
error: function(xhr, status, error) {
|
| 205 |
+
$('#loading-panel').hide();
|
| 206 |
+
let errorMsg = '获取情景预测失败';
|
| 207 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 208 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 209 |
+
} else if (error) {
|
| 210 |
+
errorMsg += ': ' + error;
|
| 211 |
+
}
|
| 212 |
+
showError(errorMsg);
|
| 213 |
+
}
|
| 214 |
+
});
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function renderScenarioPrediction(data, stockCode) {
|
| 218 |
+
// 设置乐观情景数据
|
| 219 |
+
$('#optimistic-price').text('¥' + formatNumber(data.optimistic.target_price, 2));
|
| 220 |
+
$('#optimistic-change').text(formatPercent(data.optimistic.change_percent, 2));
|
| 221 |
+
$('#optimistic-analysis').text(data.optimistic_analysis || '暂无分析');
|
| 222 |
+
|
| 223 |
+
// 设置中性情景数据
|
| 224 |
+
$('#neutral-price').text('¥' + formatNumber(data.neutral.target_price, 2));
|
| 225 |
+
$('#neutral-change').text(formatPercent(data.neutral.change_percent, 2));
|
| 226 |
+
$('#neutral-analysis').text(data.neutral_analysis || '暂无分析');
|
| 227 |
+
|
| 228 |
+
// 设置悲观情景数据
|
| 229 |
+
$('#pessimistic-price').text('¥' + formatNumber(data.pessimistic.target_price, 2));
|
| 230 |
+
$('#pessimistic-change').text(formatPercent(data.pessimistic.change_percent, 2));
|
| 231 |
+
$('#pessimistic-analysis').text(data.pessimistic_analysis || '暂无分析');
|
| 232 |
+
|
| 233 |
+
// 设置风险与机会因素
|
| 234 |
+
setDefaultRiskOpportunityFactors();
|
| 235 |
+
|
| 236 |
+
// 渲染价格预测图表
|
| 237 |
+
renderPricePredictionChart(data);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function setDefaultRiskOpportunityFactors() {
|
| 241 |
+
// 示例风险因素
|
| 242 |
+
const riskFactors = [
|
| 243 |
+
'宏观经济下行压力增大',
|
| 244 |
+
'行业政策收紧可能性',
|
| 245 |
+
'原材料价格上涨',
|
| 246 |
+
'市场竞争加剧',
|
| 247 |
+
'技术迭代风险'
|
| 248 |
+
];
|
| 249 |
+
|
| 250 |
+
// 示例有利因素
|
| 251 |
+
const opportunityFactors = [
|
| 252 |
+
'行业景气度持续向好',
|
| 253 |
+
'公司新产品上市',
|
| 254 |
+
'成本控制措施见效',
|
| 255 |
+
'产能扩张计划',
|
| 256 |
+
'国际市场开拓机会'
|
| 257 |
+
];
|
| 258 |
+
|
| 259 |
+
// 填充HTML
|
| 260 |
+
$('#risk-factors').html('');
|
| 261 |
+
riskFactors.forEach(factor => {
|
| 262 |
+
$('#risk-factors').append(`<li>${factor}</li>`);
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
$('#opportunity-factors').html('');
|
| 266 |
+
opportunityFactors.forEach(factor => {
|
| 267 |
+
$('#opportunity-factors').append(`<li>${factor}</li>`);
|
| 268 |
+
});
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
function renderPricePredictionChart(data) {
|
| 272 |
+
// 准备数据
|
| 273 |
+
const currentPrice = data.current_price;
|
| 274 |
+
|
| 275 |
+
// 提取日期和价格路径
|
| 276 |
+
const dates = Object.keys(data.optimistic.path);
|
| 277 |
+
const optimisticPrices = Object.values(data.optimistic.path);
|
| 278 |
+
const neutralPrices = Object.values(data.neutral.path);
|
| 279 |
+
const pessimisticPrices = Object.values(data.pessimistic.path);
|
| 280 |
+
|
| 281 |
+
const options = {
|
| 282 |
+
series: [
|
| 283 |
+
{
|
| 284 |
+
name: '乐观情景',
|
| 285 |
+
data: optimisticPrices.map((price, i) => ({
|
| 286 |
+
x: new Date(dates[i]),
|
| 287 |
+
y: price
|
| 288 |
+
}))
|
| 289 |
+
},
|
| 290 |
+
{
|
| 291 |
+
name: '中性情景',
|
| 292 |
+
data: neutralPrices.map((price, i) => ({
|
| 293 |
+
x: new Date(dates[i]),
|
| 294 |
+
y: price
|
| 295 |
+
}))
|
| 296 |
+
},
|
| 297 |
+
{
|
| 298 |
+
name: '悲观情景',
|
| 299 |
+
data: pessimisticPrices.map((price, i) => ({
|
| 300 |
+
x: new Date(dates[i]),
|
| 301 |
+
y: price
|
| 302 |
+
}))
|
| 303 |
+
}
|
| 304 |
+
],
|
| 305 |
+
chart: {
|
| 306 |
+
height: 400,
|
| 307 |
+
type: 'line',
|
| 308 |
+
zoom: {
|
| 309 |
+
enabled: true
|
| 310 |
+
},
|
| 311 |
+
toolbar: {
|
| 312 |
+
show: true
|
| 313 |
+
}
|
| 314 |
+
},
|
| 315 |
+
colors: ['#20E647', '#2E93fA', '#FF4560'],
|
| 316 |
+
dataLabels: {
|
| 317 |
+
enabled: false
|
| 318 |
+
},
|
| 319 |
+
stroke: {
|
| 320 |
+
curve: 'smooth',
|
| 321 |
+
width: [3, 3, 3]
|
| 322 |
+
},
|
| 323 |
+
title: {
|
| 324 |
+
text: '多情景预测',
|
| 325 |
+
align: 'left'
|
| 326 |
+
},
|
| 327 |
+
grid: {
|
| 328 |
+
borderColor: '#e7e7e7',
|
| 329 |
+
row: {
|
| 330 |
+
colors: ['#f3f3f3', 'transparent'],
|
| 331 |
+
opacity: 0.5
|
| 332 |
+
},
|
| 333 |
+
},
|
| 334 |
+
markers: {
|
| 335 |
+
size: 1
|
| 336 |
+
},
|
| 337 |
+
xaxis: {
|
| 338 |
+
type: 'datetime',
|
| 339 |
+
title: {
|
| 340 |
+
text: '日期'
|
| 341 |
+
}
|
| 342 |
+
},
|
| 343 |
+
yaxis: {
|
| 344 |
+
title: {
|
| 345 |
+
text: '价格 (¥)'
|
| 346 |
+
},
|
| 347 |
+
labels: {
|
| 348 |
+
formatter: function(val) {
|
| 349 |
+
return formatNumber(val, 2);
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
},
|
| 353 |
+
legend: {
|
| 354 |
+
position: 'top',
|
| 355 |
+
horizontalAlign: 'right'
|
| 356 |
+
},
|
| 357 |
+
tooltip: {
|
| 358 |
+
shared: true,
|
| 359 |
+
intersect: false,
|
| 360 |
+
y: {
|
| 361 |
+
formatter: function(value) {
|
| 362 |
+
return '¥' + formatNumber(value, 2);
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
},
|
| 366 |
+
annotations: {
|
| 367 |
+
yaxis: [
|
| 368 |
+
{
|
| 369 |
+
y: currentPrice,
|
| 370 |
+
borderColor: '#000',
|
| 371 |
+
label: {
|
| 372 |
+
borderColor: '#000',
|
| 373 |
+
style: {
|
| 374 |
+
color: '#fff',
|
| 375 |
+
background: '#000'
|
| 376 |
+
},
|
| 377 |
+
text: '当前价格: ¥' + formatNumber(currentPrice, 2)
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
]
|
| 381 |
+
}
|
| 382 |
+
};
|
| 383 |
+
|
| 384 |
+
const chart = new ApexCharts(document.querySelector("#price-prediction-chart"), options);
|
| 385 |
+
chart.render();
|
| 386 |
+
}
|
| 387 |
+
</script>
|
| 388 |
+
{% endblock %}
|
templates/stock_detail.html
ADDED
|
@@ -0,0 +1,1200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}股票详情 - {{ stock_code }} - 智能分析系统{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container-fluid py-1" style="padding-top: 0.2rem !important; padding-bottom: 0.2rem !important;">
|
| 7 |
+
|
| 8 |
+
<div id="alerts-container"></div>
|
| 9 |
+
|
| 10 |
+
<!-- 调整布局: 减少垂直方向上的间距 -->
|
| 11 |
+
<div class="d-flex justify-content-between align-items-center mb-1" style="margin-top: 0.2rem; margin-bottom: 0.2rem;">
|
| 12 |
+
<h4 id="stock-title" class="mb-0 fw-bold" style="font-size: 1.1rem; line-height: 1.2;">股票详情加载中...</h4>
|
| 13 |
+
<div class="d-flex align-items-center">
|
| 14 |
+
<select class="form-select form-select-sm me-2" id="market-type" style="max-width: 100px; height: 32px; padding-top: 2px; padding-bottom: 2px;">
|
| 15 |
+
<option value="A" {% if market_type == 'A' %}selected{% endif %}>A股</option>
|
| 16 |
+
<option value="HK" {% if market_type == 'HK' %}selected{% endif %}>港股</option>
|
| 17 |
+
<option value="US" {% if market_type == 'US' %}selected{% endif %}>美股</option>
|
| 18 |
+
</select>
|
| 19 |
+
<select class="form-select form-select-sm me-2" id="analysis-period" style="max-width: 100px; height: 32px; padding-top: 2px; padding-bottom: 2px;">
|
| 20 |
+
<option value="1m">1个月</option>
|
| 21 |
+
<option value="3m">3个月</option>
|
| 22 |
+
<option value="6m">6个月</option>
|
| 23 |
+
<option value="1y" selected>1年</option>
|
| 24 |
+
</select>
|
| 25 |
+
<button id="refresh-btn" class="btn btn-primary btn-sm" style="height: 32px; padding: 2px 8px;">
|
| 26 |
+
<i class="fas fa-sync-alt"></i>
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div id="loading-panel" class="text-center py-5">
|
| 32 |
+
<div class="spinner-border text-primary" role="status">
|
| 33 |
+
<span class="visually-hidden">Loading...</span>
|
| 34 |
+
</div>
|
| 35 |
+
<p class="mt-3 mb-0">正在加载股票数据和分析结果...</p>
|
| 36 |
+
<p class="text-muted small mt-2">
|
| 37 |
+
<i class="fas fa-info-circle"></i>
|
| 38 |
+
AI分析需要30-300秒,已处理<span id="processing-time">0</span>秒
|
| 39 |
+
</p>
|
| 40 |
+
<div class="progress mt-3" style="height: 5px;">
|
| 41 |
+
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
|
| 42 |
+
</div>
|
| 43 |
+
<button id="cancel-analysis-btn" class="btn btn-outline-secondary mt-3">
|
| 44 |
+
<i class="fas fa-times"></i> 取消分析
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div id="error-retry" class="text-center mt-3" style="display: none;">
|
| 49 |
+
<button id="retry-button" class="btn btn-primary mt-2">
|
| 50 |
+
<i class="fas fa-sync-alt"></i> 重试分析
|
| 51 |
+
</button>
|
| 52 |
+
<p class="text-muted small mt-2">
|
| 53 |
+
如果重试失败,请访问<a href="/dashboard">仪表盘</a>尝试其他股票
|
| 54 |
+
</p>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div id="analysis-result" style="display: none;">
|
| 58 |
+
<div class="row g-3 mb-3">
|
| 59 |
+
<div class="col-md-6">
|
| 60 |
+
<div class="card h-100">
|
| 61 |
+
<div class="card-header py-2">
|
| 62 |
+
<h5 class="mb-0">股票概要</h5>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="card-body">
|
| 65 |
+
<div class="row mb-3">
|
| 66 |
+
<div class="col-md-7">
|
| 67 |
+
<h3 id="stock-name" class="mb-0 fs-4"></h3>
|
| 68 |
+
<p id="stock-info" class="text-muted mb-0 small"></p>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="col-md-5 text-end">
|
| 71 |
+
<h2 id="stock-price" class="mb-0 fs-4"></h2>
|
| 72 |
+
<p id="price-change" class="mb-0"></p>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="row">
|
| 76 |
+
<div class="col-md-6">
|
| 77 |
+
<div class="mb-2">
|
| 78 |
+
<span class="text-muted small">综合评分:</span>
|
| 79 |
+
<div class="mt-1">
|
| 80 |
+
<span id="total-score" class="badge rounded-pill score-pill"></span>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="mb-2">
|
| 84 |
+
<span class="text-muted small">投资建议:</span>
|
| 85 |
+
<p id="recommendation" class="mb-0 text-strong"></p>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="col-md-6">
|
| 89 |
+
<div class="mb-2">
|
| 90 |
+
<span class="text-muted small">技术面指标:</span>
|
| 91 |
+
<ul class="list-unstyled mt-1 mb-0 small">
|
| 92 |
+
<li><span class="text-muted">RSI:</span> <span id="rsi-value"></span></li>
|
| 93 |
+
<li><span class="text-muted">MA趋势:</span> <span id="ma-trend"></span></li>
|
| 94 |
+
<li><span class="text-muted">MACD信号:</span> <span id="macd-signal"></span></li>
|
| 95 |
+
<li><span class="text-muted">成交量:</span> <span id="volume-status"></span></li>
|
| 96 |
+
</ul>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="col-md-6">
|
| 104 |
+
<div class="card h-100">
|
| 105 |
+
<div class="card-header py-2">
|
| 106 |
+
<h5 class="mb-0">多维度评分</h5>
|
| 107 |
+
</div>
|
| 108 |
+
<div class="card-body">
|
| 109 |
+
<div class="row">
|
| 110 |
+
<div class="col-md-6">
|
| 111 |
+
<div id="score-chart" style="height: 180px;"></div>
|
| 112 |
+
</div>
|
| 113 |
+
<div class="col-md-6">
|
| 114 |
+
<div id="radar-chart" style="height: 180px;"></div>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div class="row g-3 mb-3">
|
| 123 |
+
<div class="col-12">
|
| 124 |
+
<div class="card">
|
| 125 |
+
<div class="card-header py-2">
|
| 126 |
+
<h5 class="mb-0">价格与技术指标</h5>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="card-body p-0">
|
| 129 |
+
<div id="price-chart" style="height: 400px;"></div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div class="row g-3 mb-3">
|
| 136 |
+
<div class="col-12">
|
| 137 |
+
<div class="card">
|
| 138 |
+
<div class="card-header py-2">
|
| 139 |
+
<h5 class="mb-0">MACD & RSI 指标</h5>
|
| 140 |
+
</div>
|
| 141 |
+
<div class="card-body p-0">
|
| 142 |
+
<div id="indicators-chart" style="height: 350px;"></div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div class="row g-3 mb-3">
|
| 149 |
+
<div class="col-md-4">
|
| 150 |
+
<div class="card h-100">
|
| 151 |
+
<div class="card-header py-2">
|
| 152 |
+
<h5 class="mb-0">支撑与压力位</h5>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="card-body">
|
| 155 |
+
<table class="table table-sm">
|
| 156 |
+
<thead>
|
| 157 |
+
<tr>
|
| 158 |
+
<th>类型</th>
|
| 159 |
+
<th>价格</th>
|
| 160 |
+
<th>距离</th>
|
| 161 |
+
</tr>
|
| 162 |
+
</thead>
|
| 163 |
+
<tbody id="support-resistance-table">
|
| 164 |
+
<!-- 支撑压力位数据将在JS中动态填充 -->
|
| 165 |
+
</tbody>
|
| 166 |
+
</table>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="col-md-8">
|
| 171 |
+
<div class="card h-100">
|
| 172 |
+
<div class="card-header py-2">
|
| 173 |
+
<h5 class="mb-0">AI分析建议</h5>
|
| 174 |
+
</div>
|
| 175 |
+
<div class="card-body">
|
| 176 |
+
<div id="ai-analysis" class="analysis-section">
|
| 177 |
+
<!-- AI分析结果将在JS中动态填充 -->
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
{% endblock %}
|
| 186 |
+
|
| 187 |
+
{% block scripts %}
|
| 188 |
+
<script>
|
| 189 |
+
const stockCode = '{{ stock_code }}';
|
| 190 |
+
let marketType = '{{ market_type }}';
|
| 191 |
+
let period = '1y';
|
| 192 |
+
let stockData = [];
|
| 193 |
+
let analysisResult = null;
|
| 194 |
+
|
| 195 |
+
$(document).ready(function() {
|
| 196 |
+
// 初始加载
|
| 197 |
+
loadStockData();
|
| 198 |
+
|
| 199 |
+
// 刷新按钮点击事件
|
| 200 |
+
$('#refresh-btn').click(function() {
|
| 201 |
+
marketType = $('#market-type').val();
|
| 202 |
+
period = $('#analysis-period').val();
|
| 203 |
+
loadStockData();
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
// 市场类型改变事件
|
| 207 |
+
$('#market-type').change(function() {
|
| 208 |
+
marketType = $(this).val();
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// 分析周期改变事件
|
| 212 |
+
$('#analysis-period').change(function() {
|
| 213 |
+
period = $(this).val();
|
| 214 |
+
});
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
// 加载股票数据
|
| 218 |
+
function loadStockData() {
|
| 219 |
+
$('#loading-panel').show();
|
| 220 |
+
$('#analysis-result').hide();
|
| 221 |
+
|
| 222 |
+
// 获取股票数据
|
| 223 |
+
$.ajax({
|
| 224 |
+
url: `/api/stock_data?stock_code=${stockCode}&market_type=${marketType}&period=${period}`,
|
| 225 |
+
type: 'GET',
|
| 226 |
+
dataType: 'json',
|
| 227 |
+
success: function(response) {
|
| 228 |
+
|
| 229 |
+
// 检查response是否有data属性
|
| 230 |
+
if (!response.data) {
|
| 231 |
+
$('#loading-panel').hide();
|
| 232 |
+
showError('响应格式不正确: 缺少data字段');
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
if (response.data.length === 0) {
|
| 237 |
+
$('#loading-panel').hide();
|
| 238 |
+
showError('未找到股票数据');
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
stockData = response.data;
|
| 243 |
+
|
| 244 |
+
// 获取增强分析数据
|
| 245 |
+
loadAnalysisResult();
|
| 246 |
+
},
|
| 247 |
+
error: function(xhr, status, error) {
|
| 248 |
+
$('#loading-panel').hide();
|
| 249 |
+
|
| 250 |
+
let errorMsg = '获取股票数据失败';
|
| 251 |
+
if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 252 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 253 |
+
} else if (error) {
|
| 254 |
+
errorMsg += ': ' + error;
|
| 255 |
+
}
|
| 256 |
+
showError(errorMsg);
|
| 257 |
+
}
|
| 258 |
+
});
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// 加载分析结果
|
| 262 |
+
function loadAnalysisResult() {
|
| 263 |
+
|
| 264 |
+
// 显示加载状态并启动进度更新
|
| 265 |
+
$('#loading-panel').show();
|
| 266 |
+
$('#analysis-result').hide();
|
| 267 |
+
$('#error-retry').hide();
|
| 268 |
+
|
| 269 |
+
// 添加处理时间计数器
|
| 270 |
+
let processingTime = 0;
|
| 271 |
+
const processingTimer = setInterval(function() {
|
| 272 |
+
processingTime++;
|
| 273 |
+
$('#processing-time').text(processingTime);
|
| 274 |
+
}, 1000);
|
| 275 |
+
|
| 276 |
+
// 使用新的API启动分析任务
|
| 277 |
+
$.ajax({
|
| 278 |
+
url: '/api/start_stock_analysis',
|
| 279 |
+
type: 'POST',
|
| 280 |
+
contentType: 'application/json',
|
| 281 |
+
data: JSON.stringify({
|
| 282 |
+
stock_code: stockCode,
|
| 283 |
+
market_type: marketType
|
| 284 |
+
}),
|
| 285 |
+
success: function(response) {
|
| 286 |
+
|
| 287 |
+
// 检查是否已有结果
|
| 288 |
+
if (response.status === 'completed' && response.result) {
|
| 289 |
+
// 任务已完成,直接处理结果
|
| 290 |
+
handleAnalysisResult(response.result);
|
| 291 |
+
clearInterval(processingTimer);
|
| 292 |
+
return;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
// 开始轮询任务状态
|
| 296 |
+
pollAnalysisStatus(response.task_id, processingTime, processingTimer);
|
| 297 |
+
},
|
| 298 |
+
error: function(xhr, status, error) {
|
| 299 |
+
clearInterval(processingTimer);
|
| 300 |
+
handleAnalysisError(xhr, status, error);
|
| 301 |
+
}
|
| 302 |
+
});
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// 轮询分析任务状态
|
| 306 |
+
function pollAnalysisStatus(taskId, startTime, timerInterval) {
|
| 307 |
+
let elapsedTime = startTime || 0;
|
| 308 |
+
let pollInterval;
|
| 309 |
+
|
| 310 |
+
// 保存当前任务ID,用于取消
|
| 311 |
+
window.currentAnalysisTaskId = taskId;
|
| 312 |
+
|
| 313 |
+
// 立即执行一次,然后设置定时器
|
| 314 |
+
checkStatus();
|
| 315 |
+
|
| 316 |
+
function checkStatus() {
|
| 317 |
+
$.ajax({
|
| 318 |
+
url: `/api/analysis_status/${taskId}`,
|
| 319 |
+
type: 'GET',
|
| 320 |
+
success: function(response) {
|
| 321 |
+
// 更新计时和进度
|
| 322 |
+
elapsedTime = startTime + 1;
|
| 323 |
+
const progress = response.progress || 0;
|
| 324 |
+
|
| 325 |
+
// 更新进度显示
|
| 326 |
+
$('#processing-time').text(elapsedTime);
|
| 327 |
+
|
| 328 |
+
// 根据任务状态处理
|
| 329 |
+
if (response.status === 'completed') {
|
| 330 |
+
// 分析完成,停止轮询
|
| 331 |
+
clearInterval(pollInterval);
|
| 332 |
+
clearInterval(timerInterval);
|
| 333 |
+
|
| 334 |
+
// 处理结果
|
| 335 |
+
handleAnalysisResult(response.result);
|
| 336 |
+
} else if (response.status === 'failed') {
|
| 337 |
+
// 分析失败,停止轮询
|
| 338 |
+
clearInterval(pollInterval);
|
| 339 |
+
clearInterval(timerInterval);
|
| 340 |
+
|
| 341 |
+
$('#loading-panel').hide();
|
| 342 |
+
|
| 343 |
+
showError('分析失败: ' + (response.error || '未知错误'));
|
| 344 |
+
$('#error-retry').show();
|
| 345 |
+
} else {
|
| 346 |
+
// 任务仍在进行中,继续轮询
|
| 347 |
+
if (!pollInterval) {
|
| 348 |
+
pollInterval = setInterval(checkStatus, 2000);
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
},
|
| 352 |
+
error: function(xhr, status, error) {
|
| 353 |
+
if (!pollInterval) {
|
| 354 |
+
pollInterval = setInterval(checkStatus, 3000);
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
});
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// 处理分析结果
|
| 362 |
+
function handleAnalysisResult(result) {
|
| 363 |
+
try {
|
| 364 |
+
// 设置全局变量
|
| 365 |
+
analysisResult = result;
|
| 366 |
+
|
| 367 |
+
// 渲染分析结果
|
| 368 |
+
renderAnalysisResult();
|
| 369 |
+
|
| 370 |
+
// 更新UI
|
| 371 |
+
$('#loading-panel').hide();
|
| 372 |
+
$('#analysis-result').show();
|
| 373 |
+
} catch (error) {
|
| 374 |
+
$('#loading-panel').hide();
|
| 375 |
+
showError('处理分析结果时出错: ' + error.message);
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// 处理分析错误
|
| 380 |
+
function handleAnalysisError(xhr, status, error) {
|
| 381 |
+
$('#loading-panel').hide();
|
| 382 |
+
|
| 383 |
+
let errorMsg = '获取分析数据失败';
|
| 384 |
+
if (status === 'timeout') {
|
| 385 |
+
errorMsg = '请求超时,分析可能需要较长时间,请稍后再试';
|
| 386 |
+
} else if (xhr.status === 524 || xhr.status === 504) {
|
| 387 |
+
errorMsg = '请求超时,服务器处理时间过长';
|
| 388 |
+
} else if (xhr.responseJSON && xhr.responseJSON.error) {
|
| 389 |
+
errorMsg += ': ' + xhr.responseJSON.error;
|
| 390 |
+
} else if (error) {
|
| 391 |
+
errorMsg += ': ' + error;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
showError(errorMsg);
|
| 395 |
+
$('#error-retry').show();
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
// 取消按钮功能
|
| 399 |
+
$('#cancel-analysis-btn').click(function() {
|
| 400 |
+
if (window.currentAnalysisTaskId) {
|
| 401 |
+
$.ajax({
|
| 402 |
+
url: `/api/cancel_analysis/${window.currentAnalysisTaskId}`,
|
| 403 |
+
type: 'POST',
|
| 404 |
+
success: function(response) {
|
| 405 |
+
$('#loading-panel').hide();
|
| 406 |
+
showInfo('分析已取消');
|
| 407 |
+
},
|
| 408 |
+
error: function(error) {
|
| 409 |
+
console.error('取消分析失败:', error);
|
| 410 |
+
}
|
| 411 |
+
});
|
| 412 |
+
} else {
|
| 413 |
+
$('#loading-panel').hide();
|
| 414 |
+
}
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
// 重试按钮功能
|
| 418 |
+
$('#retry-button').click(function() {
|
| 419 |
+
$('#error-retry').hide();
|
| 420 |
+
loadAnalysisResult();
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
// 通用安全格式化函数
|
| 424 |
+
function safeFormat(value, decimals=2) {
|
| 425 |
+
try {
|
| 426 |
+
// 处理numpy对象残留
|
| 427 |
+
if (value && typeof value === 'object') {
|
| 428 |
+
if (value._dtype === 'float64') {
|
| 429 |
+
return parseFloat(value._values[0]).toFixed(decimals);
|
| 430 |
+
}
|
| 431 |
+
if (value._dtype === 'int64') {
|
| 432 |
+
return parseInt(value._values[0]);
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
return parseFloat(value).toFixed(decimals);
|
| 436 |
+
} catch (e) {
|
| 437 |
+
console.error('Format error:', e);
|
| 438 |
+
return '--';
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// 使用示例
|
| 443 |
+
$('#rsi-value').text(safeFormat(analysisResult.technical_analysis.indicators.rsi));
|
| 444 |
+
|
| 445 |
+
// 渲染分析结果
|
| 446 |
+
function renderAnalysisResult() {
|
| 447 |
+
if (!analysisResult) {
|
| 448 |
+
showError("分析结果为空");
|
| 449 |
+
return;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
try {
|
| 453 |
+
|
| 454 |
+
// 使用全新的安全访问函数
|
| 455 |
+
function safeGet(obj, path, defaultValue) {
|
| 456 |
+
if (!obj) return defaultValue;
|
| 457 |
+
|
| 458 |
+
const props = path.split('.');
|
| 459 |
+
let current = obj;
|
| 460 |
+
|
| 461 |
+
for (let i = 0; i < props.length; i++) {
|
| 462 |
+
if (current === undefined || current === null) {
|
| 463 |
+
console.warn(`属性路径 ${path} 在 ${props.slice(0, i).join('.')} 处中断`);
|
| 464 |
+
return defaultValue;
|
| 465 |
+
}
|
| 466 |
+
current = current[props[i]];
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
return current !== undefined && current !== null ? current : defaultValue;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// 安全检查技术分析数据
|
| 473 |
+
if (!analysisResult.technical_analysis) {
|
| 474 |
+
analysisResult.technical_analysis = {
|
| 475 |
+
trend: {ma_trend: 'UNKNOWN', ma_status: '未知', ma_values: {}},
|
| 476 |
+
indicators: {rsi: 50, macd: 0, macd_signal: 0, macd_histogram: 0, volatility: 0},
|
| 477 |
+
volume: {current_volume: 0, volume_ratio: 0, volume_status: 'NORMAL'},
|
| 478 |
+
support_resistance: {support_levels: {}, resistance_levels: {}}
|
| 479 |
+
};
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
// 使用安全函数获取所有数据
|
| 483 |
+
const stockName = safeGet(analysisResult, 'basic_info.stock_name', '未知');
|
| 484 |
+
const stockCode = safeGet(analysisResult, 'basic_info.stock_code', '未知');
|
| 485 |
+
const industry = safeGet(analysisResult, 'basic_info.industry', '未知');
|
| 486 |
+
const analysisDate = safeGet(analysisResult, 'basic_info.analysis_date', '未知日期');
|
| 487 |
+
|
| 488 |
+
// 更新页面标题
|
| 489 |
+
$('#stock-title').text(`${stockName} (${stockCode}) 股票分析`);
|
| 490 |
+
|
| 491 |
+
// 渲染股票基本信息
|
| 492 |
+
$('#stock-name').text(`${stockName} (${stockCode})`);
|
| 493 |
+
$('#stock-info').text(`${industry} | ${analysisDate}`);
|
| 494 |
+
|
| 495 |
+
// 渲染价格信息
|
| 496 |
+
const currentPrice = safeGet(analysisResult, 'price_data.current_price', 0);
|
| 497 |
+
const priceChange = safeGet(analysisResult, 'price_data.price_change', 0);
|
| 498 |
+
const priceChangeValue = safeGet(analysisResult, 'price_data.price_change_value', 0);
|
| 499 |
+
|
| 500 |
+
$('#stock-price').text('¥' + formatNumber(currentPrice, 2));
|
| 501 |
+
const priceChangeClass = priceChange >= 0 ? 'trend-up' : 'trend-down';
|
| 502 |
+
const priceChangeIcon = priceChange >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| 503 |
+
$('#price-change').html(`<span class="${priceChangeClass}">${priceChangeIcon} ${formatNumber(priceChangeValue, 2)} (${formatPercent(priceChange, 2)})</span>`);
|
| 504 |
+
|
| 505 |
+
// 渲染评分和建议
|
| 506 |
+
const totalScore = safeGet(analysisResult, 'scores.total', 0);
|
| 507 |
+
const scoreClass = getScoreColorClass(totalScore);
|
| 508 |
+
$('#total-score').text(totalScore).addClass(scoreClass);
|
| 509 |
+
$('#recommendation').text(safeGet(analysisResult, 'recommendation.action', '无建议'));
|
| 510 |
+
|
| 511 |
+
// 渲染技术指标 - 所有属性都使用安全访问
|
| 512 |
+
$('#rsi-value').text(formatNumber(safeGet(analysisResult, 'technical_analysis.indicators.rsi', 0), 2));
|
| 513 |
+
|
| 514 |
+
const maTrend = safeGet(analysisResult, 'technical_analysis.trend.ma_trend', 'UNKNOWN');
|
| 515 |
+
const maStatus = safeGet(analysisResult, 'technical_analysis.trend.ma_status', '未知');
|
| 516 |
+
const maTrendClass = getTrendColorClass(maTrend);
|
| 517 |
+
const maTrendIcon = getTrendIcon(maTrend);
|
| 518 |
+
$('#ma-trend').html(`<span class="${maTrendClass}">${maTrendIcon} ${maStatus}</span>`);
|
| 519 |
+
|
| 520 |
+
// MACD信号
|
| 521 |
+
const macd = safeGet(analysisResult, 'technical_analysis.indicators.macd', 0);
|
| 522 |
+
const macdSignal = safeGet(analysisResult, 'technical_analysis.indicators.macd_signal', 0);
|
| 523 |
+
const macdStatus = macd > macdSignal ? 'BUY' : 'SELL';
|
| 524 |
+
const macdClass = macdStatus === 'BUY' ? 'trend-up' : 'trend-down';
|
| 525 |
+
const macdIcon = macdStatus === 'BUY' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| 526 |
+
$('#macd-signal').html(`<span class="${macdClass}">${macdIcon} ${macdStatus}</span>`);
|
| 527 |
+
|
| 528 |
+
// 成交量状态
|
| 529 |
+
const volumeStatus = safeGet(analysisResult, 'technical_analysis.volume.volume_status', 'NORMAL');
|
| 530 |
+
const volumeClass = volumeStatus === 'HIGH' ? 'trend-up' : 'trend-down';
|
| 531 |
+
const volumeIcon = volumeStatus === 'HIGH' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| 532 |
+
$('#volume-status').html(`<span class="${volumeClass}">${volumeIcon} ${volumeStatus}</span>`);
|
| 533 |
+
|
| 534 |
+
// 支撑压力位 - 完全重写为更安全的版本
|
| 535 |
+
let supportResistanceHtml = '';
|
| 536 |
+
|
| 537 |
+
// 渲染压力位
|
| 538 |
+
const shortTermResistance = safeGet(analysisResult, 'technical_analysis.support_resistance.resistance_levels.short_term', []);
|
| 539 |
+
if (shortTermResistance.length > 0) {
|
| 540 |
+
const resistance = shortTermResistance[0];
|
| 541 |
+
const distance = ((resistance - currentPrice) / currentPrice * 100).toFixed(2);
|
| 542 |
+
supportResistanceHtml += `
|
| 543 |
+
<tr>
|
| 544 |
+
<td><span class="badge bg-danger">短期压力</span></td>
|
| 545 |
+
<td>${formatNumber(resistance, 2)}</td>
|
| 546 |
+
<td>+${distance}%</td>
|
| 547 |
+
</tr>
|
| 548 |
+
`;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
const mediumTermResistance = safeGet(analysisResult, 'technical_analysis.support_resistance.resistance_levels.medium_term', []);
|
| 552 |
+
if (mediumTermResistance.length > 0) {
|
| 553 |
+
const resistance = mediumTermResistance[0];
|
| 554 |
+
const distance = ((resistance - currentPrice) / currentPrice * 100).toFixed(2);
|
| 555 |
+
supportResistanceHtml += `
|
| 556 |
+
<tr>
|
| 557 |
+
<td><span class="badge bg-warning text-dark">中期压力</span></td>
|
| 558 |
+
<td>${formatNumber(resistance, 2)}</td>
|
| 559 |
+
<td>+${distance}%</td>
|
| 560 |
+
</tr>
|
| 561 |
+
`;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
// 渲染支撑位
|
| 565 |
+
const shortTermSupport = safeGet(analysisResult, 'technical_analysis.support_resistance.support_levels.short_term', []);
|
| 566 |
+
if (shortTermSupport.length > 0) {
|
| 567 |
+
const support = shortTermSupport[0];
|
| 568 |
+
const distance = ((support - currentPrice) / currentPrice * 100).toFixed(2);
|
| 569 |
+
supportResistanceHtml += `
|
| 570 |
+
<tr>
|
| 571 |
+
<td><span class="badge bg-success">短期支撑</span></td>
|
| 572 |
+
<td>${formatNumber(support, 2)}</td>
|
| 573 |
+
<td>${distance}%</td>
|
| 574 |
+
</tr>
|
| 575 |
+
`;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
const mediumTermSupport = safeGet(analysisResult, 'technical_analysis.support_resistance.support_levels.medium_term', []);
|
| 579 |
+
if (mediumTermSupport.length > 0) {
|
| 580 |
+
const support = mediumTermSupport[0];
|
| 581 |
+
const distance = ((support - currentPrice) / currentPrice * 100).toFixed(2);
|
| 582 |
+
supportResistanceHtml += `
|
| 583 |
+
<tr>
|
| 584 |
+
<td><span class="badge bg-info">中期支撑</span></td>
|
| 585 |
+
<td>${formatNumber(support, 2)}</td>
|
| 586 |
+
<td>${distance}%</td>
|
| 587 |
+
</tr>
|
| 588 |
+
`;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
if (supportResistanceHtml === '') {
|
| 592 |
+
supportResistanceHtml = '<tr><td colspan="3" class="text-center">暂无支撑压力位数据</td></tr>';
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
$('#support-resistance-table').html(supportResistanceHtml);
|
| 596 |
+
|
| 597 |
+
// 渲染AI分析
|
| 598 |
+
const aiAnalysis = safeGet(analysisResult, 'ai_analysis', '暂无AI分析');
|
| 599 |
+
$('#ai-analysis').html(formatAIAnalysis(aiAnalysis));
|
| 600 |
+
|
| 601 |
+
// 安全地绘制图表
|
| 602 |
+
try {
|
| 603 |
+
renderScoreChart();
|
| 604 |
+
} catch (e) {
|
| 605 |
+
$('#score-chart').html('<div class="text-center text-muted">评分图表渲染失败</div>');
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
try {
|
| 609 |
+
renderRadarChart();
|
| 610 |
+
} catch (e) {
|
| 611 |
+
$('#radar-chart').html('<div class="text-center text-muted">雷达图表渲染失败</div>');
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
try {
|
| 615 |
+
renderPriceChart();
|
| 616 |
+
} catch (e) {
|
| 617 |
+
$('#price-chart').html('<div class="text-center text-muted">价格图表渲染失败</div>');
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
try {
|
| 621 |
+
renderIndicatorsChart();
|
| 622 |
+
} catch (e) {
|
| 623 |
+
$('#indicators-chart').html('<div class="text-center text-muted">指标图表渲染失败</div>');
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
} catch (error) {
|
| 627 |
+
showError(`渲染分析结果时出错: ${error.message}`);
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
// 绘制评分图表
|
| 632 |
+
function renderScoreChart() {
|
| 633 |
+
if (!analysisResult) return;
|
| 634 |
+
|
| 635 |
+
const totalScore = analysisResult.scores.total || 0;
|
| 636 |
+
|
| 637 |
+
const options = {
|
| 638 |
+
series: [totalScore],
|
| 639 |
+
chart: {
|
| 640 |
+
height: 180,
|
| 641 |
+
type: 'radialBar',
|
| 642 |
+
toolbar: {
|
| 643 |
+
show: false
|
| 644 |
+
}
|
| 645 |
+
},
|
| 646 |
+
plotOptions: {
|
| 647 |
+
radialBar: {
|
| 648 |
+
hollow: {
|
| 649 |
+
size: '70%',
|
| 650 |
+
},
|
| 651 |
+
dataLabels: {
|
| 652 |
+
showOn: 'always',
|
| 653 |
+
name: {
|
| 654 |
+
show: true,
|
| 655 |
+
fontSize: '14px',
|
| 656 |
+
fontWeight: 600,
|
| 657 |
+
offsetY: -10
|
| 658 |
+
},
|
| 659 |
+
value: {
|
| 660 |
+
formatter: function(val) {
|
| 661 |
+
return val;
|
| 662 |
+
},
|
| 663 |
+
fontSize: '22px',
|
| 664 |
+
fontWeight: 700,
|
| 665 |
+
offsetY: 5
|
| 666 |
+
}
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
},
|
| 670 |
+
fill: {
|
| 671 |
+
type: 'gradient',
|
| 672 |
+
gradient: {
|
| 673 |
+
shade: 'dark',
|
| 674 |
+
type: 'horizontal',
|
| 675 |
+
gradientToColors: ['#ABE5A1'],
|
| 676 |
+
stops: [0, 100]
|
| 677 |
+
}
|
| 678 |
+
},
|
| 679 |
+
stroke: {
|
| 680 |
+
lineCap: 'round'
|
| 681 |
+
},
|
| 682 |
+
labels: ['总分'],
|
| 683 |
+
colors: ['#20E647']
|
| 684 |
+
};
|
| 685 |
+
|
| 686 |
+
// 清除旧图表
|
| 687 |
+
$('#score-chart').empty();
|
| 688 |
+
|
| 689 |
+
const chart = new ApexCharts(document.querySelector("#score-chart"), options);
|
| 690 |
+
chart.render();
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
// 绘制雷达图
|
| 694 |
+
function renderRadarChart() {
|
| 695 |
+
if (!analysisResult) return;
|
| 696 |
+
|
| 697 |
+
const trendScore = analysisResult.scores.trend || 0;
|
| 698 |
+
const indicatorsScore = analysisResult.scores.indicators || 0;
|
| 699 |
+
const supportResistanceScore = analysisResult.scores.support_resistance || 0;
|
| 700 |
+
const volatilityVolumeScore = analysisResult.scores.volatility_volume || 0;
|
| 701 |
+
|
| 702 |
+
const options = {
|
| 703 |
+
series: [{
|
| 704 |
+
name: '评分',
|
| 705 |
+
data: [
|
| 706 |
+
trendScore,
|
| 707 |
+
indicatorsScore,
|
| 708 |
+
supportResistanceScore,
|
| 709 |
+
volatilityVolumeScore
|
| 710 |
+
]
|
| 711 |
+
}],
|
| 712 |
+
chart: {
|
| 713 |
+
height: 180,
|
| 714 |
+
type: 'radar',
|
| 715 |
+
toolbar: {
|
| 716 |
+
show: false
|
| 717 |
+
}
|
| 718 |
+
},
|
| 719 |
+
xaxis: {
|
| 720 |
+
categories: ['趋势', '指标', '支压', '波动量']
|
| 721 |
+
},
|
| 722 |
+
yaxis: {
|
| 723 |
+
max: 10,
|
| 724 |
+
min: 0
|
| 725 |
+
},
|
| 726 |
+
fill: {
|
| 727 |
+
opacity: 0.5,
|
| 728 |
+
colors: ['#4e73df']
|
| 729 |
+
},
|
| 730 |
+
markers: {
|
| 731 |
+
size: 4
|
| 732 |
+
}
|
| 733 |
+
};
|
| 734 |
+
|
| 735 |
+
// 清除旧图表
|
| 736 |
+
$('#radar-chart').empty();
|
| 737 |
+
|
| 738 |
+
const chart = new ApexCharts(document.querySelector("#radar-chart"), options);
|
| 739 |
+
chart.render();
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
// 绘制价格图表
|
| 743 |
+
function renderPriceChart() {
|
| 744 |
+
|
| 745 |
+
try {
|
| 746 |
+
// Create a fully separate array for each price series (no OHLC array)
|
| 747 |
+
const closePrices = stockData.map(item => {
|
| 748 |
+
// 处理numpy日期格式
|
| 749 |
+
let dateStr = item.date;
|
| 750 |
+
if (dateStr && typeof dateStr === 'object') {
|
| 751 |
+
dateStr = dateStr.toString().split('T')[0];
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
return {
|
| 755 |
+
x: new Date(dateStr + 'T00:00:00'), // 标准化日期
|
| 756 |
+
y: safeFormat(item.close)
|
| 757 |
+
};
|
| 758 |
+
});
|
| 759 |
+
|
| 760 |
+
const ma5Data = stockData.map(item => ({
|
| 761 |
+
x: new Date(item.date),
|
| 762 |
+
y: parseFloat(item.MA5 || 0)
|
| 763 |
+
}));
|
| 764 |
+
|
| 765 |
+
const ma20Data = stockData.map(item => ({
|
| 766 |
+
x: new Date(item.date),
|
| 767 |
+
y: parseFloat(item.MA20 || 0)
|
| 768 |
+
}));
|
| 769 |
+
|
| 770 |
+
const ma60Data = stockData.map(item => ({
|
| 771 |
+
x: new Date(item.date),
|
| 772 |
+
y: parseFloat(item.MA60 || 0)
|
| 773 |
+
}));
|
| 774 |
+
|
| 775 |
+
// Create chart options using line chart instead of candlestick
|
| 776 |
+
const priceOptions = {
|
| 777 |
+
series: [
|
| 778 |
+
{
|
| 779 |
+
name: '收盘价',
|
| 780 |
+
type: 'line',
|
| 781 |
+
data: closePrices
|
| 782 |
+
},
|
| 783 |
+
{
|
| 784 |
+
name: 'MA5',
|
| 785 |
+
type: 'line',
|
| 786 |
+
data: ma5Data
|
| 787 |
+
},
|
| 788 |
+
{
|
| 789 |
+
name: 'MA20',
|
| 790 |
+
type: 'line',
|
| 791 |
+
data: ma20Data
|
| 792 |
+
},
|
| 793 |
+
{
|
| 794 |
+
name: 'MA60',
|
| 795 |
+
type: 'line',
|
| 796 |
+
data: ma60Data
|
| 797 |
+
}
|
| 798 |
+
],
|
| 799 |
+
chart: {
|
| 800 |
+
height: 400,
|
| 801 |
+
type: 'line',
|
| 802 |
+
toolbar: {
|
| 803 |
+
show: true
|
| 804 |
+
},
|
| 805 |
+
animations: {
|
| 806 |
+
enabled: false
|
| 807 |
+
}
|
| 808 |
+
},
|
| 809 |
+
stroke: {
|
| 810 |
+
width: [3, 2, 2, 2],
|
| 811 |
+
curve: 'straight'
|
| 812 |
+
},
|
| 813 |
+
title: {
|
| 814 |
+
text: `价格走势图`,
|
| 815 |
+
align: 'left'
|
| 816 |
+
},
|
| 817 |
+
xaxis: {
|
| 818 |
+
type: 'datetime'
|
| 819 |
+
},
|
| 820 |
+
yaxis: {
|
| 821 |
+
labels: {
|
| 822 |
+
formatter: function(value) {
|
| 823 |
+
return formatNumber(value, 2);
|
| 824 |
+
}
|
| 825 |
+
}
|
| 826 |
+
},
|
| 827 |
+
tooltip: {
|
| 828 |
+
enabled: true,
|
| 829 |
+
shared: true,
|
| 830 |
+
intersect: false,
|
| 831 |
+
x: {
|
| 832 |
+
format: 'yyyy-MM-dd'
|
| 833 |
+
},
|
| 834 |
+
y: {
|
| 835 |
+
formatter: function(value) {
|
| 836 |
+
return formatNumber(value, 2);
|
| 837 |
+
}
|
| 838 |
+
}
|
| 839 |
+
},
|
| 840 |
+
legend: {
|
| 841 |
+
show: true,
|
| 842 |
+
position: 'top',
|
| 843 |
+
horizontalAlign: 'left'
|
| 844 |
+
},
|
| 845 |
+
markers: {
|
| 846 |
+
size: 0
|
| 847 |
+
},
|
| 848 |
+
grid: {
|
| 849 |
+
show: true
|
| 850 |
+
}
|
| 851 |
+
};
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
$('#price-chart').empty();
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
const chart = new ApexCharts(document.querySelector("#price-chart"), priceOptions);
|
| 858 |
+
|
| 859 |
+
chart.render();
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
} catch (error) {
|
| 863 |
+
// Show error message
|
| 864 |
+
$('#price-chart').html('<div class="alert alert-danger">图表加载失败: ' + error.message + '</div>');
|
| 865 |
+
}
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
// Format AI analysis text
|
| 869 |
+
function formatAIAnalysis(text) {
|
| 870 |
+
if (!text) return '';
|
| 871 |
+
|
| 872 |
+
// First, make the text safe for HTML
|
| 873 |
+
const safeText = text
|
| 874 |
+
.replace(/&/g, '&')
|
| 875 |
+
.replace(/</g, '<')
|
| 876 |
+
.replace(/>/g, '>');
|
| 877 |
+
|
| 878 |
+
// Replace basic Markdown elements
|
| 879 |
+
let formatted = safeText
|
| 880 |
+
// Bold text with ** or __
|
| 881 |
+
.replace(/\*\*(.*?)\*\*/g, '<strong class="keyword">$1</strong>')
|
| 882 |
+
.replace(/__(.*?)__/g, '<strong>$1</strong>')
|
| 883 |
+
|
| 884 |
+
// Italic text with * or _
|
| 885 |
+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
| 886 |
+
.replace(/_(.*?)_/g, '<em>$1</em>')
|
| 887 |
+
|
| 888 |
+
// Headers
|
| 889 |
+
.replace(/^# (.*?)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
|
| 890 |
+
.replace(/^## (.*?)$/gm, '<h5 class="mt-2 mb-2">$1</h5>')
|
| 891 |
+
|
| 892 |
+
// Apply special styling to financial terms
|
| 893 |
+
.replace(/支撑位/g, '<span class="keyword">支撑位</span>')
|
| 894 |
+
.replace(/压力位/g, '<span class="keyword">压力位</span>')
|
| 895 |
+
.replace(/趋势/g, '<span class="keyword">趋势</span>')
|
| 896 |
+
.replace(/均线/g, '<span class="keyword">均线</span>')
|
| 897 |
+
.replace(/MACD/g, '<span class="term">MACD</span>')
|
| 898 |
+
.replace(/RSI/g, '<span class="term">RSI</span>')
|
| 899 |
+
.replace(/KDJ/g, '<span class="term">KDJ</span>')
|
| 900 |
+
|
| 901 |
+
// Highlight price patterns and movements
|
| 902 |
+
.replace(/([上涨升])/g, '<span class="trend-up">$1</span>')
|
| 903 |
+
.replace(/([下跌降])/g, '<span class="trend-down">$1</span>')
|
| 904 |
+
.replace(/(买入|做多|多头|突破)/g, '<span class="trend-up">$1</span>')
|
| 905 |
+
.replace(/(卖出|做空|空头|跌破)/g, '<span class="trend-down">$1</span>')
|
| 906 |
+
|
| 907 |
+
// Highlight price values (matches patterns like 31.25, 120.50)
|
| 908 |
+
.replace(/(\d+\.\d{2})/g, '<span class="price">$1</span>')
|
| 909 |
+
|
| 910 |
+
// Convert line breaks to paragraph tags
|
| 911 |
+
.replace(/\n\n+/g, '</p><p class="analysis-para">')
|
| 912 |
+
.replace(/\n/g, '<br>');
|
| 913 |
+
|
| 914 |
+
// Wrap in paragraph tags for consistent styling
|
| 915 |
+
return '<p class="analysis-para">' + formatted + '</p>';
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
function renderPriceChartWithOHLC() {
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
try {
|
| 922 |
+
// Create OHLC data table to display below the chart
|
| 923 |
+
let ohlcTableHtml = '<div class="ohlc-data mt-3"><h6>价格数据 (最近5天)</h6><table class="table table-sm table-bordered">';
|
| 924 |
+
ohlcTableHtml += '<thead><tr><th>日期</th><th>开盘</th><th>最高</th><th>最低</th><th>收盘</th></tr></thead><tbody>';
|
| 925 |
+
|
| 926 |
+
// Get last 5 days of data
|
| 927 |
+
const recentData = stockData.slice(-5);
|
| 928 |
+
recentData.forEach(item => {
|
| 929 |
+
ohlcTableHtml += `<tr>
|
| 930 |
+
<td>${item.date}</td>
|
| 931 |
+
<td>${formatNumber(item.open, 2)}</td>
|
| 932 |
+
<td>${formatNumber(item.high, 2)}</td>
|
| 933 |
+
<td>${formatNumber(item.low, 2)}</td>
|
| 934 |
+
<td>${formatNumber(item.close, 2)}</td>
|
| 935 |
+
</tr>`;
|
| 936 |
+
});
|
| 937 |
+
ohlcTableHtml += '</tbody></table></div>';
|
| 938 |
+
|
| 939 |
+
// Create price line chart
|
| 940 |
+
const closePrices = stockData.map(item => {
|
| 941 |
+
// 处理numpy日期格式
|
| 942 |
+
let dateStr = item.date;
|
| 943 |
+
if (dateStr && typeof dateStr === 'object') {
|
| 944 |
+
dateStr = dateStr.toString().split('T')[0];
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
return {
|
| 948 |
+
x: new Date(dateStr + 'T00:00:00'), // 标准化日期
|
| 949 |
+
y: safeFormat(item.close)
|
| 950 |
+
};
|
| 951 |
+
});
|
| 952 |
+
|
| 953 |
+
const ma5Data = stockData.map(item => ({
|
| 954 |
+
x: new Date(item.date),
|
| 955 |
+
y: parseFloat(item.MA5 || 0)
|
| 956 |
+
}));
|
| 957 |
+
|
| 958 |
+
const ma20Data = stockData.map(item => ({
|
| 959 |
+
x: new Date(item.date),
|
| 960 |
+
y: parseFloat(item.MA20 || 0)
|
| 961 |
+
}));
|
| 962 |
+
|
| 963 |
+
const ma60Data = stockData.map(item => ({
|
| 964 |
+
x: new Date(item.date),
|
| 965 |
+
y: parseFloat(item.MA60 || 0)
|
| 966 |
+
}));
|
| 967 |
+
|
| 968 |
+
const priceOptions = {
|
| 969 |
+
series: [
|
| 970 |
+
{
|
| 971 |
+
name: '收盘价',
|
| 972 |
+
type: 'line',
|
| 973 |
+
data: closePrices
|
| 974 |
+
},
|
| 975 |
+
{
|
| 976 |
+
name: 'MA5',
|
| 977 |
+
type: 'line',
|
| 978 |
+
data: ma5Data
|
| 979 |
+
},
|
| 980 |
+
{
|
| 981 |
+
name: 'MA20',
|
| 982 |
+
type: 'line',
|
| 983 |
+
data: ma20Data
|
| 984 |
+
},
|
| 985 |
+
{
|
| 986 |
+
name: 'MA60',
|
| 987 |
+
type: 'line',
|
| 988 |
+
data: ma60Data
|
| 989 |
+
}
|
| 990 |
+
],
|
| 991 |
+
chart: {
|
| 992 |
+
height: 350,
|
| 993 |
+
type: 'line',
|
| 994 |
+
toolbar: {
|
| 995 |
+
show: true
|
| 996 |
+
},
|
| 997 |
+
animations: {
|
| 998 |
+
enabled: false
|
| 999 |
+
}
|
| 1000 |
+
},
|
| 1001 |
+
stroke: {
|
| 1002 |
+
width: [3, 2, 2, 2]
|
| 1003 |
+
},
|
| 1004 |
+
colors: ['#FF4560', '#008FFB', '#00E396', '#775DD0'],
|
| 1005 |
+
title: {
|
| 1006 |
+
text: `价格走势图 (收盘价)`,
|
| 1007 |
+
align: 'left'
|
| 1008 |
+
},
|
| 1009 |
+
xaxis: {
|
| 1010 |
+
type: 'datetime'
|
| 1011 |
+
},
|
| 1012 |
+
yaxis: {
|
| 1013 |
+
labels: {
|
| 1014 |
+
formatter: function(value) {
|
| 1015 |
+
return formatNumber(value, 2);
|
| 1016 |
+
}
|
| 1017 |
+
}
|
| 1018 |
+
},
|
| 1019 |
+
tooltip: {
|
| 1020 |
+
enabled: true,
|
| 1021 |
+
shared: true,
|
| 1022 |
+
intersect: false,
|
| 1023 |
+
x: {
|
| 1024 |
+
format: 'yyyy-MM-dd'
|
| 1025 |
+
}
|
| 1026 |
+
},
|
| 1027 |
+
legend: {
|
| 1028 |
+
show: true
|
| 1029 |
+
}
|
| 1030 |
+
};
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
// Clear the chart div and prepare it for the chart + table
|
| 1034 |
+
$('#price-chart').empty();
|
| 1035 |
+
|
| 1036 |
+
// Create a container for the chart
|
| 1037 |
+
$('#price-chart').append('<div id="price-line-chart"></div>');
|
| 1038 |
+
|
| 1039 |
+
const chart = new ApexCharts(document.querySelector("#price-line-chart"), priceOptions);
|
| 1040 |
+
|
| 1041 |
+
chart.render();
|
| 1042 |
+
|
| 1043 |
+
// Append the OHLC table below the chart
|
| 1044 |
+
$('#price-chart').append(ohlcTableHtml);
|
| 1045 |
+
|
| 1046 |
+
} catch (error) {
|
| 1047 |
+
$('#price-chart').html('<div class="alert alert-danger">图表加载失败: ' + error.message + '</div>');
|
| 1048 |
+
}
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
|
| 1052 |
+
// 绘制技术指标图表
|
| 1053 |
+
function renderIndicatorsChart() {
|
| 1054 |
+
|
| 1055 |
+
try {
|
| 1056 |
+
// Create chart options inline without using variables
|
| 1057 |
+
const indicatorOptions = {
|
| 1058 |
+
series: [
|
| 1059 |
+
{
|
| 1060 |
+
name: 'MACD',
|
| 1061 |
+
type: 'line',
|
| 1062 |
+
data: stockData.map(item => ({
|
| 1063 |
+
x: new Date(item.date),
|
| 1064 |
+
y: parseFloat(item.MACD || 0)
|
| 1065 |
+
}))
|
| 1066 |
+
},
|
| 1067 |
+
{
|
| 1068 |
+
name: 'Signal',
|
| 1069 |
+
type: 'line',
|
| 1070 |
+
data: stockData.map(item => ({
|
| 1071 |
+
x: new Date(item.date),
|
| 1072 |
+
y: parseFloat(item.Signal || 0)
|
| 1073 |
+
}))
|
| 1074 |
+
},
|
| 1075 |
+
{
|
| 1076 |
+
name: 'Histogram',
|
| 1077 |
+
type: 'bar',
|
| 1078 |
+
data: stockData.map(item => ({
|
| 1079 |
+
x: new Date(item.date),
|
| 1080 |
+
y: parseFloat(item.MACD_hist || 0)
|
| 1081 |
+
}))
|
| 1082 |
+
},
|
| 1083 |
+
{
|
| 1084 |
+
name: 'RSI',
|
| 1085 |
+
type: 'line',
|
| 1086 |
+
data: stockData.map(item => ({
|
| 1087 |
+
x: new Date(item.date),
|
| 1088 |
+
y: parseFloat(item.RSI || 0)
|
| 1089 |
+
}))
|
| 1090 |
+
}
|
| 1091 |
+
],
|
| 1092 |
+
chart: {
|
| 1093 |
+
height: 350,
|
| 1094 |
+
type: 'line',
|
| 1095 |
+
stacked: false,
|
| 1096 |
+
toolbar: {
|
| 1097 |
+
show: true
|
| 1098 |
+
},
|
| 1099 |
+
// Disable animations
|
| 1100 |
+
animations: {
|
| 1101 |
+
enabled: false
|
| 1102 |
+
}
|
| 1103 |
+
},
|
| 1104 |
+
stroke: {
|
| 1105 |
+
width: [3, 3, 0, 3],
|
| 1106 |
+
curve: 'smooth'
|
| 1107 |
+
},
|
| 1108 |
+
xaxis: {
|
| 1109 |
+
type: 'datetime'
|
| 1110 |
+
},
|
| 1111 |
+
yaxis: [
|
| 1112 |
+
{
|
| 1113 |
+
title: {
|
| 1114 |
+
text: 'MACD',
|
| 1115 |
+
},
|
| 1116 |
+
seriesName: 'MACD',
|
| 1117 |
+
labels: {
|
| 1118 |
+
formatter: function(value) {
|
| 1119 |
+
return formatNumber(value, 3);
|
| 1120 |
+
}
|
| 1121 |
+
}
|
| 1122 |
+
},
|
| 1123 |
+
{
|
| 1124 |
+
show: false,
|
| 1125 |
+
seriesName: 'Signal'
|
| 1126 |
+
},
|
| 1127 |
+
{
|
| 1128 |
+
show: false,
|
| 1129 |
+
seriesName: 'Histogram'
|
| 1130 |
+
},
|
| 1131 |
+
{
|
| 1132 |
+
opposite: true,
|
| 1133 |
+
title: {
|
| 1134 |
+
text: 'RSI'
|
| 1135 |
+
},
|
| 1136 |
+
min: 0,
|
| 1137 |
+
max: 100,
|
| 1138 |
+
seriesName: 'RSI',
|
| 1139 |
+
labels: {
|
| 1140 |
+
formatter: function(value) {
|
| 1141 |
+
return formatNumber(value, 2);
|
| 1142 |
+
}
|
| 1143 |
+
}
|
| 1144 |
+
}
|
| 1145 |
+
],
|
| 1146 |
+
// Simplified tooltip configuration
|
| 1147 |
+
tooltip: {
|
| 1148 |
+
enabled: true,
|
| 1149 |
+
shared: true,
|
| 1150 |
+
intersect: false, // Important for preventing null element errors
|
| 1151 |
+
hideEmptySeries: true,
|
| 1152 |
+
x: {
|
| 1153 |
+
format: 'yyyy-MM-dd'
|
| 1154 |
+
},
|
| 1155 |
+
y: {
|
| 1156 |
+
formatter: function(value, { seriesIndex }) {
|
| 1157 |
+
// Simplified formatter function
|
| 1158 |
+
if (seriesIndex === 0) return `MACD: ${formatNumber(value, 3)}`;
|
| 1159 |
+
if (seriesIndex === 1) return `Signal: ${formatNumber(value, 3)}`;
|
| 1160 |
+
if (seriesIndex === 2) return `Histogram: ${formatNumber(value, 3)}`;
|
| 1161 |
+
if (seriesIndex === 3) return `RSI: ${formatNumber(value, 2)}`;
|
| 1162 |
+
return formatNumber(value, 2);
|
| 1163 |
+
}
|
| 1164 |
+
}
|
| 1165 |
+
},
|
| 1166 |
+
colors: ['#008FFB', '#00E396', '#CED4DC', '#FEB019'],
|
| 1167 |
+
legend: {
|
| 1168 |
+
show: true,
|
| 1169 |
+
position: 'top',
|
| 1170 |
+
horizontalAlign: 'left',
|
| 1171 |
+
floating: false
|
| 1172 |
+
},
|
| 1173 |
+
// Explicitly set marker options to prevent errors
|
| 1174 |
+
markers: {
|
| 1175 |
+
size: 4,
|
| 1176 |
+
strokeWidth: 0
|
| 1177 |
+
}
|
| 1178 |
+
};
|
| 1179 |
+
|
| 1180 |
+
$('#indicators-chart').empty();
|
| 1181 |
+
|
| 1182 |
+
const chart = new ApexCharts(document.querySelector("#indicators-chart"), indicatorOptions);
|
| 1183 |
+
|
| 1184 |
+
chart.render();
|
| 1185 |
+
|
| 1186 |
+
} catch (error) {
|
| 1187 |
+
$('#indicators-chart').html('<div class="alert alert-danger">指标图表加载失败: ' + error.message + '</div>');
|
| 1188 |
+
}
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
// 添加到script部分
|
| 1192 |
+
$('#retry-button').click(function() {
|
| 1193 |
+
// 隐藏错误和重试区域
|
| 1194 |
+
$('#error-retry').hide();
|
| 1195 |
+
// 重新加载分析
|
| 1196 |
+
loadAnalysisResult();
|
| 1197 |
+
});
|
| 1198 |
+
|
| 1199 |
+
</script>
|
| 1200 |
+
{% endblock %}
|
us_stock_service.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
修改:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
"""
|
| 7 |
+
# us_stock_service.py
|
| 8 |
+
import akshare as ak
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class USStockService:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
logging.basicConfig(level=logging.INFO,
|
| 16 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
| 17 |
+
self.logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
def search_us_stocks(self, keyword):
|
| 20 |
+
"""
|
| 21 |
+
搜索美股代码
|
| 22 |
+
:param keyword: 搜索关键词
|
| 23 |
+
:return: 匹配的股票列表
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
# 获取美股数据
|
| 27 |
+
df = ak.stock_us_spot_em()
|
| 28 |
+
|
| 29 |
+
# 转换列名
|
| 30 |
+
df = df.rename(columns={
|
| 31 |
+
"序号": "index",
|
| 32 |
+
"名称": "name",
|
| 33 |
+
"最新价": "price",
|
| 34 |
+
"涨跌额": "price_change",
|
| 35 |
+
"涨跌幅": "price_change_percent",
|
| 36 |
+
"开盘价": "open",
|
| 37 |
+
"最高价": "high",
|
| 38 |
+
"最低价": "low",
|
| 39 |
+
"昨收价": "pre_close",
|
| 40 |
+
"总市值": "market_value",
|
| 41 |
+
"市盈率": "pe_ratio",
|
| 42 |
+
"成交量": "volume",
|
| 43 |
+
"成交额": "turnover",
|
| 44 |
+
"振幅": "amplitude",
|
| 45 |
+
"换手率": "turnover_rate",
|
| 46 |
+
"代码": "symbol"
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
# 模糊匹配搜索
|
| 50 |
+
mask = df['name'].str.contains(keyword, case=False, na=False)
|
| 51 |
+
results = df[mask]
|
| 52 |
+
|
| 53 |
+
# 格式化返回结果并处理 NaN 值
|
| 54 |
+
formatted_results = []
|
| 55 |
+
for _, row in results.iterrows():
|
| 56 |
+
formatted_results.append({
|
| 57 |
+
'name': row['name'] if pd.notna(row['name']) else '',
|
| 58 |
+
'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '',
|
| 59 |
+
'price': float(row['price']) if pd.notna(row['price']) else 0.0,
|
| 60 |
+
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
return formatted_results
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
self.logger.error(f"搜索美股代码时出错: {str(e)}")
|
| 67 |
+
raise Exception(f"搜索美股代码失败: {str(e)}")
|
web_server.py
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
智能分析系统(股票) - 股票市场数据分析系统
|
| 4 |
+
修改:熊猫大侠
|
| 5 |
+
版本:v2.1.0
|
| 6 |
+
"""
|
| 7 |
+
# web_server.py
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
| 12 |
+
from stock_analyzer import StockAnalyzer
|
| 13 |
+
from us_stock_service import USStockService
|
| 14 |
+
import threading
|
| 15 |
+
import logging
|
| 16 |
+
from logging.handlers import RotatingFileHandler
|
| 17 |
+
import traceback
|
| 18 |
+
import os
|
| 19 |
+
import json
|
| 20 |
+
from datetime import date, datetime, timedelta
|
| 21 |
+
from flask_cors import CORS
|
| 22 |
+
import time
|
| 23 |
+
from flask_caching import Cache
|
| 24 |
+
import threading
|
| 25 |
+
import sys
|
| 26 |
+
from flask_swagger_ui import get_swaggerui_blueprint
|
| 27 |
+
from database import get_session, StockInfo, AnalysisResult, Portfolio, USE_DATABASE
|
| 28 |
+
from dotenv import load_dotenv
|
| 29 |
+
from industry_analyzer import IndustryAnalyzer
|
| 30 |
+
|
| 31 |
+
# 加载环境变量
|
| 32 |
+
load_dotenv()
|
| 33 |
+
|
| 34 |
+
# 检查是否需要初始化数据库
|
| 35 |
+
if USE_DATABASE:
|
| 36 |
+
init_db()
|
| 37 |
+
|
| 38 |
+
# 配置Swagger
|
| 39 |
+
SWAGGER_URL = '/api/docs'
|
| 40 |
+
API_URL = '/static/swagger.json'
|
| 41 |
+
swaggerui_blueprint = get_swaggerui_blueprint(
|
| 42 |
+
SWAGGER_URL,
|
| 43 |
+
API_URL,
|
| 44 |
+
config={
|
| 45 |
+
'app_name': "股票智能分析系统 API文档"
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
app = Flask(__name__)
|
| 50 |
+
CORS(app, resources={r"/*": {"origins": "*"}})
|
| 51 |
+
analyzer = StockAnalyzer()
|
| 52 |
+
us_stock_service = USStockService()
|
| 53 |
+
|
| 54 |
+
# 配置缓存
|
| 55 |
+
cache_config = {
|
| 56 |
+
'CACHE_TYPE': 'SimpleCache',
|
| 57 |
+
'CACHE_DEFAULT_TIMEOUT': 300
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# 如果配置了Redis,使用Redis作为缓存后端
|
| 61 |
+
if os.getenv('USE_REDIS_CACHE', 'False').lower() == 'true' and os.getenv('REDIS_URL'):
|
| 62 |
+
cache_config = {
|
| 63 |
+
'CACHE_TYPE': 'RedisCache',
|
| 64 |
+
'CACHE_REDIS_URL': os.getenv('REDIS_URL'),
|
| 65 |
+
'CACHE_DEFAULT_TIMEOUT': 300
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
cache = Cache(config={'CACHE_TYPE': 'SimpleCache'})
|
| 69 |
+
cache.init_app(app)
|
| 70 |
+
|
| 71 |
+
app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL)
|
| 72 |
+
|
| 73 |
+
# 确保全局变量在重新加载时不会丢失
|
| 74 |
+
if 'analyzer' not in globals():
|
| 75 |
+
try:
|
| 76 |
+
from stock_analyzer import StockAnalyzer
|
| 77 |
+
|
| 78 |
+
analyzer = StockAnalyzer()
|
| 79 |
+
print("成功初始化全局StockAnalyzer实例")
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"初始化StockAnalyzer时出错: {e}", file=sys.stderr)
|
| 82 |
+
raise
|
| 83 |
+
|
| 84 |
+
# 导入新模块
|
| 85 |
+
from fundamental_analyzer import FundamentalAnalyzer
|
| 86 |
+
from capital_flow_analyzer import CapitalFlowAnalyzer
|
| 87 |
+
from scenario_predictor import ScenarioPredictor
|
| 88 |
+
from stock_qa import StockQA
|
| 89 |
+
from risk_monitor import RiskMonitor
|
| 90 |
+
from index_industry_analyzer import IndexIndustryAnalyzer
|
| 91 |
+
|
| 92 |
+
# 初始化模块实例
|
| 93 |
+
fundamental_analyzer = FundamentalAnalyzer()
|
| 94 |
+
capital_flow_analyzer = CapitalFlowAnalyzer()
|
| 95 |
+
scenario_predictor = ScenarioPredictor(analyzer, os.getenv('OPENAI_API_KEY'), os.getenv('OPENAI_API_MODEL'))
|
| 96 |
+
stock_qa = StockQA(analyzer, os.getenv('OPENAI_API_KEY'), os.getenv('OPENAI_API_MODEL'))
|
| 97 |
+
risk_monitor = RiskMonitor(analyzer)
|
| 98 |
+
index_industry_analyzer = IndexIndustryAnalyzer(analyzer)
|
| 99 |
+
industry_analyzer = IndustryAnalyzer()
|
| 100 |
+
|
| 101 |
+
# 线程本地存储
|
| 102 |
+
thread_local = threading.local()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_analyzer():
|
| 106 |
+
"""获取线程本地的分析器实例"""
|
| 107 |
+
# 如果线程本地存储中没有分析器实例,创建一个新的
|
| 108 |
+
if not hasattr(thread_local, 'analyzer'):
|
| 109 |
+
thread_local.analyzer = StockAnalyzer()
|
| 110 |
+
return thread_local.analyzer
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# 配置日志
|
| 114 |
+
logging.basicConfig(level=logging.INFO)
|
| 115 |
+
handler = RotatingFileHandler('flask_app.log', maxBytes=10000000, backupCount=5)
|
| 116 |
+
handler.setFormatter(logging.Formatter(
|
| 117 |
+
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
|
| 118 |
+
))
|
| 119 |
+
app.logger.addHandler(handler)
|
| 120 |
+
|
| 121 |
+
# 扩展任务管理系统以支持不同类型的任务
|
| 122 |
+
task_types = {
|
| 123 |
+
'scan': 'market_scan', # 市场扫描任务
|
| 124 |
+
'analysis': 'stock_analysis' # 个股分析任务
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
# 任务数据存储
|
| 128 |
+
tasks = {
|
| 129 |
+
'market_scan': {}, # 原来的scan_tasks
|
| 130 |
+
'stock_analysis': {} # 新的个股分析任务
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def get_task_store(task_type):
|
| 135 |
+
"""获取指定类型的任务存储"""
|
| 136 |
+
return tasks.get(task_type, {})
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def generate_task_key(task_type, **params):
|
| 140 |
+
"""生成任务键"""
|
| 141 |
+
if task_type == 'stock_analysis':
|
| 142 |
+
# 对于个股分析,使用股票代码和市场类型作为键
|
| 143 |
+
return f"{params.get('stock_code')}_{params.get('market_type', 'A')}"
|
| 144 |
+
return None # 其他任务类型不使用预生成的键
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def get_or_create_task(task_type, **params):
|
| 148 |
+
"""获取或创建任务"""
|
| 149 |
+
store = get_task_store(task_type)
|
| 150 |
+
task_key = generate_task_key(task_type, **params)
|
| 151 |
+
|
| 152 |
+
# 检查是否有现有任务
|
| 153 |
+
if task_key and task_key in store:
|
| 154 |
+
task = store[task_key]
|
| 155 |
+
# 检查任务是否仍然有效
|
| 156 |
+
if task['status'] in [TASK_PENDING, TASK_RUNNING]:
|
| 157 |
+
return task['id'], task, False
|
| 158 |
+
if task['status'] == TASK_COMPLETED and 'result' in task:
|
| 159 |
+
# 任务已完成且有结果,重用它
|
| 160 |
+
return task['id'], task, False
|
| 161 |
+
|
| 162 |
+
# 创建新任务
|
| 163 |
+
task_id = generate_task_id()
|
| 164 |
+
task = {
|
| 165 |
+
'id': task_id,
|
| 166 |
+
'key': task_key, # 存储任务键以便以后查找
|
| 167 |
+
'type': task_type,
|
| 168 |
+
'status': TASK_PENDING,
|
| 169 |
+
'progress': 0,
|
| 170 |
+
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 171 |
+
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 172 |
+
'params': params
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
with task_lock:
|
| 176 |
+
if task_key:
|
| 177 |
+
store[task_key] = task
|
| 178 |
+
store[task_id] = task
|
| 179 |
+
|
| 180 |
+
return task_id, task, True
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# 添加到web_server.py顶部
|
| 184 |
+
# 任务管理系统
|
| 185 |
+
scan_tasks = {} # 存储扫描任务的状态和结果
|
| 186 |
+
task_lock = threading.Lock() # 用于线程安全操作
|
| 187 |
+
|
| 188 |
+
# 任务状态常量
|
| 189 |
+
TASK_PENDING = 'pending'
|
| 190 |
+
TASK_RUNNING = 'running'
|
| 191 |
+
TASK_COMPLETED = 'completed'
|
| 192 |
+
TASK_FAILED = 'failed'
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def generate_task_id():
|
| 196 |
+
"""生成唯一的任务ID"""
|
| 197 |
+
import uuid
|
| 198 |
+
return str(uuid.uuid4())
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def start_market_scan_task_status(task_id, status, progress=None, result=None, error=None):
|
| 202 |
+
"""更新任务状态 - 保持原有签名"""
|
| 203 |
+
with task_lock:
|
| 204 |
+
if task_id in scan_tasks:
|
| 205 |
+
task = scan_tasks[task_id]
|
| 206 |
+
task['status'] = status
|
| 207 |
+
if progress is not None:
|
| 208 |
+
task['progress'] = progress
|
| 209 |
+
if result is not None:
|
| 210 |
+
task['result'] = result
|
| 211 |
+
if error is not None:
|
| 212 |
+
task['error'] = error
|
| 213 |
+
task['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def update_task_status(task_type, task_id, status, progress=None, result=None, error=None):
|
| 217 |
+
"""更新任务状态"""
|
| 218 |
+
store = get_task_store(task_type)
|
| 219 |
+
with task_lock:
|
| 220 |
+
if task_id in store:
|
| 221 |
+
task = store[task_id]
|
| 222 |
+
task['status'] = status
|
| 223 |
+
if progress is not None:
|
| 224 |
+
task['progress'] = progress
|
| 225 |
+
if result is not None:
|
| 226 |
+
task['result'] = result
|
| 227 |
+
if error is not None:
|
| 228 |
+
task['error'] = error
|
| 229 |
+
task['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 230 |
+
|
| 231 |
+
# 更新键索引的任务
|
| 232 |
+
if 'key' in task and task['key'] in store:
|
| 233 |
+
store[task['key']] = task
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
analysis_tasks = {}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def get_or_create_analysis_task(stock_code, market_type='A'):
|
| 240 |
+
"""获取或创建个股分析任务"""
|
| 241 |
+
# 创建一个键,用于查找现有任务
|
| 242 |
+
task_key = f"{stock_code}_{market_type}"
|
| 243 |
+
|
| 244 |
+
with task_lock:
|
| 245 |
+
# 检查是否有现有任务
|
| 246 |
+
for task_id, task in analysis_tasks.items():
|
| 247 |
+
if task.get('key') == task_key:
|
| 248 |
+
# 检查任务是否仍然有效
|
| 249 |
+
if task['status'] in [TASK_PENDING, TASK_RUNNING]:
|
| 250 |
+
return task_id, task, False
|
| 251 |
+
if task['status'] == TASK_COMPLETED and 'result' in task:
|
| 252 |
+
# 任务已完成且有结果,重用它
|
| 253 |
+
return task_id, task, False
|
| 254 |
+
|
| 255 |
+
# 创建新任务
|
| 256 |
+
task_id = generate_task_id()
|
| 257 |
+
task = {
|
| 258 |
+
'id': task_id,
|
| 259 |
+
'key': task_key,
|
| 260 |
+
'status': TASK_PENDING,
|
| 261 |
+
'progress': 0,
|
| 262 |
+
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 263 |
+
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 264 |
+
'params': {
|
| 265 |
+
'stock_code': stock_code,
|
| 266 |
+
'market_type': market_type
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
analysis_tasks[task_id] = task
|
| 271 |
+
|
| 272 |
+
return task_id, task, True
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def update_analysis_task(task_id, status, progress=None, result=None, error=None):
|
| 276 |
+
"""更新个股分析任务状态"""
|
| 277 |
+
with task_lock:
|
| 278 |
+
if task_id in analysis_tasks:
|
| 279 |
+
task = analysis_tasks[task_id]
|
| 280 |
+
task['status'] = status
|
| 281 |
+
if progress is not None:
|
| 282 |
+
task['progress'] = progress
|
| 283 |
+
if result is not None:
|
| 284 |
+
task['result'] = result
|
| 285 |
+
if error is not None:
|
| 286 |
+
task['error'] = error
|
| 287 |
+
task['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# 定义自定义JSON编码器
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# 在web_server.py中,更新convert_numpy_types函数以处理NaN值
|
| 294 |
+
|
| 295 |
+
# 将NumPy类型转换为Python原生类型的函数
|
| 296 |
+
def convert_numpy_types(obj):
|
| 297 |
+
"""递归地将字典和列表中的NumPy类型转换为Python原生类型"""
|
| 298 |
+
try:
|
| 299 |
+
import numpy as np
|
| 300 |
+
import math
|
| 301 |
+
|
| 302 |
+
if isinstance(obj, dict):
|
| 303 |
+
return {key: convert_numpy_types(value) for key, value in obj.items()}
|
| 304 |
+
elif isinstance(obj, list):
|
| 305 |
+
return [convert_numpy_types(item) for item in obj]
|
| 306 |
+
elif isinstance(obj, np.integer):
|
| 307 |
+
return int(obj)
|
| 308 |
+
elif isinstance(obj, np.floating):
|
| 309 |
+
# Handle NaN and Infinity specifically
|
| 310 |
+
if np.isnan(obj):
|
| 311 |
+
return None
|
| 312 |
+
elif np.isinf(obj):
|
| 313 |
+
return None if obj < 0 else 1e308 # Use a very large number for +Infinity
|
| 314 |
+
return float(obj)
|
| 315 |
+
elif isinstance(obj, np.ndarray):
|
| 316 |
+
return obj.tolist()
|
| 317 |
+
elif isinstance(obj, np.bool_):
|
| 318 |
+
return bool(obj)
|
| 319 |
+
# Handle Python's own float NaN and Infinity
|
| 320 |
+
elif isinstance(obj, float):
|
| 321 |
+
if math.isnan(obj):
|
| 322 |
+
return None
|
| 323 |
+
elif math.isinf(obj):
|
| 324 |
+
return None
|
| 325 |
+
return obj
|
| 326 |
+
# 添加对date和datetime类型的处理
|
| 327 |
+
elif isinstance(obj, (date, datetime)):
|
| 328 |
+
return obj.isoformat()
|
| 329 |
+
else:
|
| 330 |
+
return obj
|
| 331 |
+
except ImportError:
|
| 332 |
+
# 如果没有安装numpy,但需要处理date和datetime
|
| 333 |
+
import math
|
| 334 |
+
if isinstance(obj, dict):
|
| 335 |
+
return {key: convert_numpy_types(value) for key, value in obj.items()}
|
| 336 |
+
elif isinstance(obj, list):
|
| 337 |
+
return [convert_numpy_types(item) for item in obj]
|
| 338 |
+
elif isinstance(obj, (date, datetime)):
|
| 339 |
+
return obj.isoformat()
|
| 340 |
+
# Handle Python's own float NaN and Infinity
|
| 341 |
+
elif isinstance(obj, float):
|
| 342 |
+
if math.isnan(obj):
|
| 343 |
+
return None
|
| 344 |
+
elif math.isinf(obj):
|
| 345 |
+
return None
|
| 346 |
+
return obj
|
| 347 |
+
return obj
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# 同样更新 NumpyJSONEncoder 类
|
| 351 |
+
class NumpyJSONEncoder(json.JSONEncoder):
|
| 352 |
+
def default(self, obj):
|
| 353 |
+
# For NumPy data types
|
| 354 |
+
try:
|
| 355 |
+
import numpy as np
|
| 356 |
+
import math
|
| 357 |
+
if isinstance(obj, np.integer):
|
| 358 |
+
return int(obj)
|
| 359 |
+
elif isinstance(obj, np.floating):
|
| 360 |
+
# Handle NaN and Infinity specifically
|
| 361 |
+
if np.isnan(obj):
|
| 362 |
+
return None
|
| 363 |
+
elif np.isinf(obj):
|
| 364 |
+
return None
|
| 365 |
+
return float(obj)
|
| 366 |
+
elif isinstance(obj, np.ndarray):
|
| 367 |
+
return obj.tolist()
|
| 368 |
+
elif isinstance(obj, np.bool_):
|
| 369 |
+
return bool(obj)
|
| 370 |
+
# Handle Python's own float NaN and Infinity
|
| 371 |
+
elif isinstance(obj, float):
|
| 372 |
+
if math.isnan(obj):
|
| 373 |
+
return None
|
| 374 |
+
elif math.isinf(obj):
|
| 375 |
+
return None
|
| 376 |
+
return obj
|
| 377 |
+
except ImportError:
|
| 378 |
+
# Handle Python's own float NaN and Infinity if numpy is not available
|
| 379 |
+
import math
|
| 380 |
+
if isinstance(obj, float):
|
| 381 |
+
if math.isnan(obj):
|
| 382 |
+
return None
|
| 383 |
+
elif math.isinf(obj):
|
| 384 |
+
return None
|
| 385 |
+
|
| 386 |
+
# 添加对date和datetime类型的处理
|
| 387 |
+
if isinstance(obj, (date, datetime)):
|
| 388 |
+
return obj.isoformat()
|
| 389 |
+
|
| 390 |
+
return super(NumpyJSONEncoder, self).default(obj)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# 使用我们的编码器的自定义 jsonify 函数
|
| 394 |
+
def custom_jsonify(data):
|
| 395 |
+
return app.response_class(
|
| 396 |
+
json.dumps(convert_numpy_types(data), cls=NumpyJSONEncoder),
|
| 397 |
+
mimetype='application/json'
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# 保持API兼容的路由
|
| 402 |
+
@app.route('/')
|
| 403 |
+
def index():
|
| 404 |
+
return render_template('index.html')
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@app.route('/analyze', methods=['POST'])
|
| 408 |
+
def analyze():
|
| 409 |
+
try:
|
| 410 |
+
data = request.json
|
| 411 |
+
stock_codes = data.get('stock_codes', [])
|
| 412 |
+
market_type = data.get('market_type', 'A')
|
| 413 |
+
|
| 414 |
+
if not stock_codes:
|
| 415 |
+
return jsonify({'error': '请输入代码'}), 400
|
| 416 |
+
|
| 417 |
+
app.logger.info(f"分析股票请求: {stock_codes}, 市场类型: {market_type}")
|
| 418 |
+
|
| 419 |
+
# 设置最大处理时间,每只股票10秒
|
| 420 |
+
max_time_per_stock = 10 # 秒
|
| 421 |
+
max_total_time = max(30, min(60, len(stock_codes) * max_time_per_stock)) # 至少30秒,最多60秒
|
| 422 |
+
|
| 423 |
+
start_time = time.time()
|
| 424 |
+
results = []
|
| 425 |
+
|
| 426 |
+
for stock_code in stock_codes:
|
| 427 |
+
try:
|
| 428 |
+
# 检查是否已超时
|
| 429 |
+
if time.time() - start_time > max_total_time:
|
| 430 |
+
app.logger.warning(f"分析股票请求已超过{max_total_time}秒,提前返回已处理的{len(results)}只股票")
|
| 431 |
+
break
|
| 432 |
+
|
| 433 |
+
# 使用线程本地缓存的分析器实例
|
| 434 |
+
current_analyzer = get_analyzer()
|
| 435 |
+
result = current_analyzer.quick_analyze_stock(stock_code.strip(), market_type)
|
| 436 |
+
|
| 437 |
+
app.logger.info(
|
| 438 |
+
f"分析结果: 股票={stock_code}, 名称={result.get('stock_name', '未知')}, 行业={result.get('industry', '未知')}")
|
| 439 |
+
results.append(result)
|
| 440 |
+
except Exception as e:
|
| 441 |
+
app.logger.error(f"分析股票 {stock_code} 时出错: {str(e)}")
|
| 442 |
+
results.append({
|
| 443 |
+
'stock_code': stock_code,
|
| 444 |
+
'error': str(e),
|
| 445 |
+
'stock_name': '分析失败',
|
| 446 |
+
'industry': '未知'
|
| 447 |
+
})
|
| 448 |
+
|
| 449 |
+
return jsonify({'results': results})
|
| 450 |
+
except Exception as e:
|
| 451 |
+
app.logger.error(f"分析股票时出错: {traceback.format_exc()}")
|
| 452 |
+
return jsonify({'error': str(e)}), 500
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
@app.route('/api/north_flow_history', methods=['POST'])
|
| 456 |
+
def api_north_flow_history():
|
| 457 |
+
try:
|
| 458 |
+
data = request.json
|
| 459 |
+
stock_code = data.get('stock_code')
|
| 460 |
+
days = data.get('days', 10) # 默认为10天,对应前端的默认选项
|
| 461 |
+
|
| 462 |
+
# 计算 end_date 为当前时间
|
| 463 |
+
end_date = datetime.now().strftime('%Y%m%d')
|
| 464 |
+
|
| 465 |
+
# 计算 start_date 为 end_date 减去指定的天数
|
| 466 |
+
start_date = (datetime.now() - timedelta(days=int(days))).strftime('%Y%m%d')
|
| 467 |
+
|
| 468 |
+
if not stock_code:
|
| 469 |
+
return jsonify({'error': '请提供股票代码'}), 400
|
| 470 |
+
|
| 471 |
+
# 调用北向资金历史数据方法
|
| 472 |
+
from capital_flow_analyzer import CapitalFlowAnalyzer
|
| 473 |
+
|
| 474 |
+
analyzer = CapitalFlowAnalyzer()
|
| 475 |
+
result = analyzer.get_north_flow_history(stock_code, start_date, end_date)
|
| 476 |
+
|
| 477 |
+
return custom_jsonify(result)
|
| 478 |
+
except Exception as e:
|
| 479 |
+
app.logger.error(f"获取北向资金历史数据出错: {traceback.format_exc()}")
|
| 480 |
+
return jsonify({'error': str(e)}), 500
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
@app.route('/search_us_stocks', methods=['GET'])
|
| 484 |
+
def search_us_stocks():
|
| 485 |
+
try:
|
| 486 |
+
keyword = request.args.get('keyword', '')
|
| 487 |
+
if not keyword:
|
| 488 |
+
return jsonify({'error': '请输入搜索关键词'}), 400
|
| 489 |
+
|
| 490 |
+
results = us_stock_service.search_us_stocks(keyword)
|
| 491 |
+
return jsonify({'results': results})
|
| 492 |
+
|
| 493 |
+
except Exception as e:
|
| 494 |
+
app.logger.error(f"搜索美股代码时出错: {str(e)}")
|
| 495 |
+
return jsonify({'error': str(e)}), 500
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
# 新增可视化分析页面路由
|
| 499 |
+
@app.route('/dashboard')
|
| 500 |
+
def dashboard():
|
| 501 |
+
return render_template('dashboard.html')
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
@app.route('/stock_detail/<string:stock_code>')
|
| 505 |
+
def stock_detail(stock_code):
|
| 506 |
+
market_type = request.args.get('market_type', 'A')
|
| 507 |
+
return render_template('stock_detail.html', stock_code=stock_code, market_type=market_type)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
@app.route('/portfolio')
|
| 511 |
+
def portfolio():
|
| 512 |
+
return render_template('portfolio.html')
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
@app.route('/market_scan')
|
| 516 |
+
def market_scan():
|
| 517 |
+
return render_template('market_scan.html')
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# 基本面分析页面
|
| 521 |
+
@app.route('/fundamental')
|
| 522 |
+
def fundamental():
|
| 523 |
+
return render_template('fundamental.html')
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# 资金流向页面
|
| 527 |
+
@app.route('/capital_flow')
|
| 528 |
+
def capital_flow():
|
| 529 |
+
return render_template('capital_flow.html')
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# 情景预测页面
|
| 533 |
+
@app.route('/scenario_predict')
|
| 534 |
+
def scenario_predict():
|
| 535 |
+
return render_template('scenario_predict.html')
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
# 风险监控页面
|
| 539 |
+
@app.route('/risk_monitor')
|
| 540 |
+
def risk_monitor_page():
|
| 541 |
+
return render_template('risk_monitor.html')
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
# 智能问答页面
|
| 545 |
+
@app.route('/qa')
|
| 546 |
+
def qa_page():
|
| 547 |
+
return render_template('qa.html')
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
# 行业分析页面
|
| 551 |
+
@app.route('/industry_analysis')
|
| 552 |
+
def industry_analysis():
|
| 553 |
+
return render_template('industry_analysis.html')
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def make_cache_key_with_stock():
|
| 557 |
+
"""创建包含股票代码的自定义缓存键"""
|
| 558 |
+
path = request.path
|
| 559 |
+
|
| 560 |
+
# 从请求体中获取股票代码
|
| 561 |
+
stock_code = None
|
| 562 |
+
if request.is_json:
|
| 563 |
+
stock_code = request.json.get('stock_code')
|
| 564 |
+
|
| 565 |
+
# 构建包含股票代码的键
|
| 566 |
+
if stock_code:
|
| 567 |
+
return f"{path}_{stock_code}"
|
| 568 |
+
else:
|
| 569 |
+
return path
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
@app.route('/api/start_stock_analysis', methods=['POST'])
|
| 573 |
+
def start_stock_analysis():
|
| 574 |
+
"""启动个股分析任务"""
|
| 575 |
+
try:
|
| 576 |
+
data = request.json
|
| 577 |
+
stock_code = data.get('stock_code')
|
| 578 |
+
market_type = data.get('market_type', 'A')
|
| 579 |
+
|
| 580 |
+
if not stock_code:
|
| 581 |
+
return jsonify({'error': '请输入股票代码'}), 400
|
| 582 |
+
|
| 583 |
+
app.logger.info(f"准备分析股票: {stock_code}")
|
| 584 |
+
|
| 585 |
+
# 获取或创建任务
|
| 586 |
+
task_id, task, is_new = get_or_create_task(
|
| 587 |
+
'stock_analysis',
|
| 588 |
+
stock_code=stock_code,
|
| 589 |
+
market_type=market_type
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
# 如果是已完成的任务,直接返回结果
|
| 593 |
+
if task['status'] == TASK_COMPLETED and 'result' in task:
|
| 594 |
+
app.logger.info(f"使用缓存的分析结果: {stock_code}")
|
| 595 |
+
return jsonify({
|
| 596 |
+
'task_id': task_id,
|
| 597 |
+
'status': task['status'],
|
| 598 |
+
'result': task['result']
|
| 599 |
+
})
|
| 600 |
+
|
| 601 |
+
# 如果是新创建的任务,启动后台处理
|
| 602 |
+
if is_new:
|
| 603 |
+
app.logger.info(f"创建新的分析任务: {task_id}")
|
| 604 |
+
|
| 605 |
+
# 启动后台线程执行分析
|
| 606 |
+
def run_analysis():
|
| 607 |
+
try:
|
| 608 |
+
update_task_status('stock_analysis', task_id, TASK_RUNNING, progress=10)
|
| 609 |
+
|
| 610 |
+
# 执行分析
|
| 611 |
+
result = analyzer.perform_enhanced_analysis(stock_code, market_type)
|
| 612 |
+
|
| 613 |
+
# 更新任务状态为完成
|
| 614 |
+
update_task_status('stock_analysis', task_id, TASK_COMPLETED, progress=100, result=result)
|
| 615 |
+
app.logger.info(f"分析任务 {task_id} 完成")
|
| 616 |
+
|
| 617 |
+
except Exception as e:
|
| 618 |
+
app.logger.error(f"分析任务 {task_id} 失败: {str(e)}")
|
| 619 |
+
app.logger.error(traceback.format_exc())
|
| 620 |
+
update_task_status('stock_analysis', task_id, TASK_FAILED, error=str(e))
|
| 621 |
+
|
| 622 |
+
# 启动后台线程
|
| 623 |
+
thread = threading.Thread(target=run_analysis)
|
| 624 |
+
thread.daemon = True
|
| 625 |
+
thread.start()
|
| 626 |
+
|
| 627 |
+
# 返回任务ID和状态
|
| 628 |
+
return jsonify({
|
| 629 |
+
'task_id': task_id,
|
| 630 |
+
'status': task['status'],
|
| 631 |
+
'message': f'已启动分析任务: {stock_code}'
|
| 632 |
+
})
|
| 633 |
+
|
| 634 |
+
except Exception as e:
|
| 635 |
+
app.logger.error(f"启动个股分析任务时出错: {traceback.format_exc()}")
|
| 636 |
+
return jsonify({'error': str(e)}), 500
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
@app.route('/api/analysis_status/<task_id>', methods=['GET'])
|
| 640 |
+
def get_analysis_status(task_id):
|
| 641 |
+
"""获取个股分析任务状态"""
|
| 642 |
+
store = get_task_store('stock_analysis')
|
| 643 |
+
with task_lock:
|
| 644 |
+
if task_id not in store:
|
| 645 |
+
return jsonify({'error': '找不到指定的分析任务'}), 404
|
| 646 |
+
|
| 647 |
+
task = store[task_id]
|
| 648 |
+
|
| 649 |
+
# 基本状态信息
|
| 650 |
+
status = {
|
| 651 |
+
'id': task['id'],
|
| 652 |
+
'status': task['status'],
|
| 653 |
+
'progress': task.get('progress', 0),
|
| 654 |
+
'created_at': task['created_at'],
|
| 655 |
+
'updated_at': task['updated_at']
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
# 如果任务完成,包含结果
|
| 659 |
+
if task['status'] == TASK_COMPLETED and 'result' in task:
|
| 660 |
+
status['result'] = task['result']
|
| 661 |
+
|
| 662 |
+
# 如果任务失败,包含错误信息
|
| 663 |
+
if task['status'] == TASK_FAILED and 'error' in task:
|
| 664 |
+
status['error'] = task['error']
|
| 665 |
+
|
| 666 |
+
return custom_jsonify(status)
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
@app.route('/api/cancel_analysis/<task_id>', methods=['POST'])
|
| 670 |
+
def cancel_analysis(task_id):
|
| 671 |
+
"""取消个股分析任务"""
|
| 672 |
+
store = get_task_store('stock_analysis')
|
| 673 |
+
with task_lock:
|
| 674 |
+
if task_id not in store:
|
| 675 |
+
return jsonify({'error': '找不到指定的分析任务'}), 404
|
| 676 |
+
|
| 677 |
+
task = store[task_id]
|
| 678 |
+
|
| 679 |
+
if task['status'] in [TASK_COMPLETED, TASK_FAILED]:
|
| 680 |
+
return jsonify({'message': '任务已完成或失败,无法取消'})
|
| 681 |
+
|
| 682 |
+
# 更新状态为失败
|
| 683 |
+
task['status'] = TASK_FAILED
|
| 684 |
+
task['error'] = '用户取消任务'
|
| 685 |
+
task['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 686 |
+
|
| 687 |
+
# 更新键索引的任务
|
| 688 |
+
if 'key' in task and task['key'] in store:
|
| 689 |
+
store[task['key']] = task
|
| 690 |
+
|
| 691 |
+
return jsonify({'message': '任务已取消'})
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
# 保留原有API用于向后兼容
|
| 695 |
+
@app.route('/api/enhanced_analysis', methods=['POST'])
|
| 696 |
+
def enhanced_analysis():
|
| 697 |
+
"""原增强分析API的向后兼容版本"""
|
| 698 |
+
try:
|
| 699 |
+
data = request.json
|
| 700 |
+
stock_code = data.get('stock_code')
|
| 701 |
+
market_type = data.get('market_type', 'A')
|
| 702 |
+
|
| 703 |
+
if not stock_code:
|
| 704 |
+
return custom_jsonify({'error': '请输入股票代码'}), 400
|
| 705 |
+
|
| 706 |
+
# 调用新的任务系统,但模拟同步行为
|
| 707 |
+
# 这会导致和之前一样的超时问题,但保持兼容
|
| 708 |
+
timeout = 300
|
| 709 |
+
start_time = time.time()
|
| 710 |
+
|
| 711 |
+
# 获取或创建任务
|
| 712 |
+
task_id, task, is_new = get_or_create_task(
|
| 713 |
+
'stock_analysis',
|
| 714 |
+
stock_code=stock_code,
|
| 715 |
+
market_type=market_type
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
# 如果是已完成的任务,直接返回结果
|
| 719 |
+
if task['status'] == TASK_COMPLETED and 'result' in task:
|
| 720 |
+
app.logger.info(f"使用缓存的分析结果: {stock_code}")
|
| 721 |
+
return custom_jsonify({'result': task['result']})
|
| 722 |
+
|
| 723 |
+
# 启动分析(如果是新任务)
|
| 724 |
+
if is_new:
|
| 725 |
+
# 同步执行分析
|
| 726 |
+
try:
|
| 727 |
+
result = analyzer.perform_enhanced_analysis(stock_code, market_type)
|
| 728 |
+
update_task_status('stock_analysis', task_id, TASK_COMPLETED, progress=100, result=result)
|
| 729 |
+
app.logger.info(f"分析完成: {stock_code},耗时 {time.time() - start_time:.2f} 秒")
|
| 730 |
+
return custom_jsonify({'result': result})
|
| 731 |
+
except Exception as e:
|
| 732 |
+
app.logger.error(f"分析过程中出错: {str(e)}")
|
| 733 |
+
update_task_status('stock_analysis', task_id, TASK_FAILED, error=str(e))
|
| 734 |
+
return custom_jsonify({'error': f'分析过程中出错: {str(e)}'}), 500
|
| 735 |
+
else:
|
| 736 |
+
# 已存在正在处理��任务,等待其完成
|
| 737 |
+
max_wait = timeout - (time.time() - start_time)
|
| 738 |
+
wait_interval = 0.5
|
| 739 |
+
waited = 0
|
| 740 |
+
|
| 741 |
+
while waited < max_wait:
|
| 742 |
+
with task_lock:
|
| 743 |
+
current_task = store[task_id]
|
| 744 |
+
if current_task['status'] == TASK_COMPLETED and 'result' in current_task:
|
| 745 |
+
return custom_jsonify({'result': current_task['result']})
|
| 746 |
+
if current_task['status'] == TASK_FAILED:
|
| 747 |
+
error = current_task.get('error', '任务失败,无详细信息')
|
| 748 |
+
return custom_jsonify({'error': error}), 500
|
| 749 |
+
|
| 750 |
+
time.sleep(wait_interval)
|
| 751 |
+
waited += wait_interval
|
| 752 |
+
|
| 753 |
+
# 超时
|
| 754 |
+
return custom_jsonify({'error': '处理超时,请稍后重试'}), 504
|
| 755 |
+
|
| 756 |
+
except Exception as e:
|
| 757 |
+
app.logger.error(f"执行增强版分析时出错: {traceback.format_exc()}")
|
| 758 |
+
return custom_jsonify({'error': str(e)}), 500
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
# 添加在web_server.py主代码中
|
| 762 |
+
@app.errorhandler(404)
|
| 763 |
+
def not_found(error):
|
| 764 |
+
"""处理404错误"""
|
| 765 |
+
if request.path.startswith('/api/'):
|
| 766 |
+
# 为API请求返回JSON格式的错误
|
| 767 |
+
return jsonify({
|
| 768 |
+
'error': '找不到请求的API端点',
|
| 769 |
+
'path': request.path,
|
| 770 |
+
'method': request.method
|
| 771 |
+
}), 404
|
| 772 |
+
# 为网页请求返回HTML错误页
|
| 773 |
+
return render_template('error.html', error_code=404, message="找不到请求的页面"), 404
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
@app.errorhandler(500)
|
| 777 |
+
def server_error(error):
|
| 778 |
+
"""处理500错误"""
|
| 779 |
+
app.logger.error(f"服务器错误: {str(error)}")
|
| 780 |
+
if request.path.startswith('/api/'):
|
| 781 |
+
# 为API请求返回JSON格式的错误
|
| 782 |
+
return jsonify({
|
| 783 |
+
'error': '服务器内部错误',
|
| 784 |
+
'message': str(error)
|
| 785 |
+
}), 500
|
| 786 |
+
# 为网页请求返回HTML错误页
|
| 787 |
+
return render_template('error.html', error_code=500, message="服务器内部错误"), 500
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
# Update the get_stock_data function in web_server.py to handle date formatting properly
|
| 791 |
+
@app.route('/api/stock_data', methods=['GET'])
|
| 792 |
+
@cache.cached(timeout=300, query_string=True)
|
| 793 |
+
def get_stock_data():
|
| 794 |
+
try:
|
| 795 |
+
stock_code = request.args.get('stock_code')
|
| 796 |
+
market_type = request.args.get('market_type', 'A')
|
| 797 |
+
period = request.args.get('period', '1y') # 默认1年
|
| 798 |
+
|
| 799 |
+
if not stock_code:
|
| 800 |
+
return custom_jsonify({'error': '请提供股票代码'}), 400
|
| 801 |
+
|
| 802 |
+
# 根据period计算start_date
|
| 803 |
+
end_date = datetime.now().strftime('%Y%m%d')
|
| 804 |
+
if period == '1m':
|
| 805 |
+
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
| 806 |
+
elif period == '3m':
|
| 807 |
+
start_date = (datetime.now() - timedelta(days=90)).strftime('%Y%m%d')
|
| 808 |
+
elif period == '6m':
|
| 809 |
+
start_date = (datetime.now() - timedelta(days=180)).strftime('%Y%m%d')
|
| 810 |
+
elif period == '1y':
|
| 811 |
+
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
|
| 812 |
+
else:
|
| 813 |
+
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
|
| 814 |
+
|
| 815 |
+
# 获取股票历史数据
|
| 816 |
+
app.logger.info(
|
| 817 |
+
f"获取股票 {stock_code} 的历史数据,市场: {market_type}, 起始日期: {start_date}, 结束日期: {end_date}")
|
| 818 |
+
df = analyzer.get_stock_data(stock_code, market_type, start_date, end_date)
|
| 819 |
+
|
| 820 |
+
# 计算技术指标
|
| 821 |
+
app.logger.info(f"计算股票 {stock_code} 的技术指标")
|
| 822 |
+
df = analyzer.calculate_indicators(df)
|
| 823 |
+
|
| 824 |
+
# 检查数据是否为空
|
| 825 |
+
if df.empty:
|
| 826 |
+
app.logger.warning(f"股票 {stock_code} 的数据为空")
|
| 827 |
+
return custom_jsonify({'error': '未找到股票数据'}), 404
|
| 828 |
+
|
| 829 |
+
# 将DataFrame转为JSON格式
|
| 830 |
+
app.logger.info(f"将数据转换为JSON格式,行数: {len(df)}")
|
| 831 |
+
|
| 832 |
+
# 确保日期列是字符串格式 - 修复缓存问题
|
| 833 |
+
if 'date' in df.columns:
|
| 834 |
+
try:
|
| 835 |
+
if pd.api.types.is_datetime64_any_dtype(df['date']):
|
| 836 |
+
df['date'] = df['date'].dt.strftime('%Y-%m-%d')
|
| 837 |
+
else:
|
| 838 |
+
df = df.copy()
|
| 839 |
+
df['date'] = pd.to_datetime(df['date'], errors='coerce')
|
| 840 |
+
df['date'] = df['date'].dt.strftime('%Y-%m-%d')
|
| 841 |
+
except Exception as e:
|
| 842 |
+
app.logger.error(f"处理日期列时出错: {str(e)}")
|
| 843 |
+
df['date'] = df['date'].astype(str)
|
| 844 |
+
|
| 845 |
+
# 将NaN值替换为None
|
| 846 |
+
df = df.replace({np.nan: None, np.inf: None, -np.inf: None})
|
| 847 |
+
|
| 848 |
+
records = df.to_dict('records')
|
| 849 |
+
|
| 850 |
+
app.logger.info(f"数据处理完成,返回 {len(records)} 条记录")
|
| 851 |
+
return custom_jsonify({'data': records})
|
| 852 |
+
except Exception as e:
|
| 853 |
+
app.logger.error(f"获取股票数据时出错: {str(e)}")
|
| 854 |
+
app.logger.error(traceback.format_exc())
|
| 855 |
+
return custom_jsonify({'error': str(e)}), 500
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
# @app.route('/api/market_scan', methods=['POST'])
|
| 859 |
+
# def api_market_scan():
|
| 860 |
+
# try:
|
| 861 |
+
# data = request.json
|
| 862 |
+
# stock_list = data.get('stock_list', [])
|
| 863 |
+
# min_score = data.get('min_score', 60)
|
| 864 |
+
# market_type = data.get('market_type', 'A')
|
| 865 |
+
|
| 866 |
+
# if not stock_list:
|
| 867 |
+
# return jsonify({'error': '请提供股票列表'}), 400
|
| 868 |
+
|
| 869 |
+
# # 限制股票数量,避免过长处理时间
|
| 870 |
+
# if len(stock_list) > 100:
|
| 871 |
+
# app.logger.warning(f"股票列表过长 ({len(stock_list)}只),截取前100只")
|
| 872 |
+
# stock_list = stock_list[:100]
|
| 873 |
+
|
| 874 |
+
# # 执行市场扫描
|
| 875 |
+
# app.logger.info(f"开始扫描 {len(stock_list)} 只股票,最低分数: {min_score}")
|
| 876 |
+
|
| 877 |
+
# # 使用线程池优化处理
|
| 878 |
+
# results = []
|
| 879 |
+
# max_workers = min(10, len(stock_list)) # 最多10个工作线程
|
| 880 |
+
|
| 881 |
+
# # 设置较长的超时时间
|
| 882 |
+
# timeout = 300 # 5分钟
|
| 883 |
+
|
| 884 |
+
# def scan_thread():
|
| 885 |
+
# try:
|
| 886 |
+
# return analyzer.scan_market(stock_list, min_score, market_type)
|
| 887 |
+
# except Exception as e:
|
| 888 |
+
# app.logger.error(f"扫描线程出错: {str(e)}")
|
| 889 |
+
# return []
|
| 890 |
+
|
| 891 |
+
# thread = threading.Thread(target=lambda: results.append(scan_thread()))
|
| 892 |
+
# thread.start()
|
| 893 |
+
# thread.join(timeout)
|
| 894 |
+
|
| 895 |
+
# if thread.is_alive():
|
| 896 |
+
# app.logger.error(f"市场扫描超时,已扫描 {len(stock_list)} 只股票超过 {timeout} 秒")
|
| 897 |
+
# return custom_jsonify({'error': '扫描超时,请减少股票数量或稍后再试'}), 504
|
| 898 |
+
|
| 899 |
+
# if not results or not results[0]:
|
| 900 |
+
# app.logger.warning("扫描结果为空")
|
| 901 |
+
# return custom_jsonify({'results': []})
|
| 902 |
+
|
| 903 |
+
# scan_results = results[0]
|
| 904 |
+
# app.logger.info(f"扫描完成,找到 {len(scan_results)} 只符合条件的股票")
|
| 905 |
+
|
| 906 |
+
# # 使用自定义JSON格式处理NumPy数据类型
|
| 907 |
+
# return custom_jsonify({'results': scan_results})
|
| 908 |
+
# except Exception as e:
|
| 909 |
+
# app.logger.error(f"执行市场扫描时出错: {traceback.format_exc()}")
|
| 910 |
+
# return custom_jsonify({'error': str(e)}), 500
|
| 911 |
+
|
| 912 |
+
@app.route('/api/start_market_scan', methods=['POST'])
|
| 913 |
+
def start_market_scan():
|
| 914 |
+
"""启动市场扫描任务"""
|
| 915 |
+
try:
|
| 916 |
+
data = request.json
|
| 917 |
+
stock_list = data.get('stock_list', [])
|
| 918 |
+
min_score = data.get('min_score', 60)
|
| 919 |
+
market_type = data.get('market_type', 'A')
|
| 920 |
+
|
| 921 |
+
if not stock_list:
|
| 922 |
+
return jsonify({'error': '请提供股票列表'}), 400
|
| 923 |
+
|
| 924 |
+
# 限制股票数量,避免过长处理时间
|
| 925 |
+
if len(stock_list) > 100:
|
| 926 |
+
app.logger.warning(f"股票列表过长 ({len(stock_list)}只),截取前100只")
|
| 927 |
+
stock_list = stock_list[:100]
|
| 928 |
+
|
| 929 |
+
# 创建新任务
|
| 930 |
+
task_id = generate_task_id()
|
| 931 |
+
task = {
|
| 932 |
+
'id': task_id,
|
| 933 |
+
'status': TASK_PENDING,
|
| 934 |
+
'progress': 0,
|
| 935 |
+
'total': len(stock_list),
|
| 936 |
+
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 937 |
+
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 938 |
+
'params': {
|
| 939 |
+
'stock_list': stock_list,
|
| 940 |
+
'min_score': min_score,
|
| 941 |
+
'market_type': market_type
|
| 942 |
+
}
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
with task_lock:
|
| 946 |
+
scan_tasks[task_id] = task
|
| 947 |
+
|
| 948 |
+
# 启动后台线程执行扫描
|
| 949 |
+
def run_scan():
|
| 950 |
+
try:
|
| 951 |
+
start_market_scan_task_status(task_id, TASK_RUNNING)
|
| 952 |
+
|
| 953 |
+
# 执行分批处理
|
| 954 |
+
results = []
|
| 955 |
+
total = len(stock_list)
|
| 956 |
+
batch_size = 10
|
| 957 |
+
|
| 958 |
+
for i in range(0, total, batch_size):
|
| 959 |
+
if task_id not in scan_tasks or scan_tasks[task_id]['status'] != TASK_RUNNING:
|
| 960 |
+
# 任务被取消
|
| 961 |
+
app.logger.info(f"扫描任务 {task_id} 被取消")
|
| 962 |
+
return
|
| 963 |
+
|
| 964 |
+
batch = stock_list[i:i + batch_size]
|
| 965 |
+
batch_results = []
|
| 966 |
+
|
| 967 |
+
for stock_code in batch:
|
| 968 |
+
try:
|
| 969 |
+
report = analyzer.quick_analyze_stock(stock_code, market_type)
|
| 970 |
+
if report['score'] >= min_score:
|
| 971 |
+
batch_results.append(report)
|
| 972 |
+
except Exception as e:
|
| 973 |
+
app.logger.error(f"分析股票 {stock_code} 时出错: {str(e)}")
|
| 974 |
+
continue
|
| 975 |
+
|
| 976 |
+
results.extend(batch_results)
|
| 977 |
+
|
| 978 |
+
# 更新进度
|
| 979 |
+
progress = min(100, int((i + len(batch)) / total * 100))
|
| 980 |
+
start_market_scan_task_status(task_id, TASK_RUNNING, progress=progress)
|
| 981 |
+
|
| 982 |
+
# 按得分排序
|
| 983 |
+
results.sort(key=lambda x: x['score'], reverse=True)
|
| 984 |
+
|
| 985 |
+
# 更新任务状态为完成
|
| 986 |
+
start_market_scan_task_status(task_id, TASK_COMPLETED, progress=100, result=results)
|
| 987 |
+
app.logger.info(f"扫描任务 {task_id} 完成,找到 {len(results)} 只符合条件的股票")
|
| 988 |
+
|
| 989 |
+
except Exception as e:
|
| 990 |
+
app.logger.error(f"扫描任务 {task_id} 失败: {str(e)}")
|
| 991 |
+
app.logger.error(traceback.format_exc())
|
| 992 |
+
start_market_scan_task_status(task_id, TASK_FAILED, error=str(e))
|
| 993 |
+
|
| 994 |
+
# 启动后台线程
|
| 995 |
+
thread = threading.Thread(target=run_scan)
|
| 996 |
+
thread.daemon = True
|
| 997 |
+
thread.start()
|
| 998 |
+
|
| 999 |
+
return jsonify({
|
| 1000 |
+
'task_id': task_id,
|
| 1001 |
+
'status': 'pending',
|
| 1002 |
+
'message': f'已启动扫描任务,正在处理 {len(stock_list)} 只股票'
|
| 1003 |
+
})
|
| 1004 |
+
|
| 1005 |
+
except Exception as e:
|
| 1006 |
+
app.logger.error(f"启动市场扫描任务时出错: {traceback.format_exc()}")
|
| 1007 |
+
return jsonify({'error': str(e)}), 500
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
@app.route('/api/scan_status/<task_id>', methods=['GET'])
|
| 1011 |
+
def get_scan_status(task_id):
|
| 1012 |
+
"""获取扫描任务状态"""
|
| 1013 |
+
with task_lock:
|
| 1014 |
+
if task_id not in scan_tasks:
|
| 1015 |
+
return jsonify({'error': '找不到指定的扫描任务'}), 404
|
| 1016 |
+
|
| 1017 |
+
task = scan_tasks[task_id]
|
| 1018 |
+
|
| 1019 |
+
# 基本状态信息
|
| 1020 |
+
status = {
|
| 1021 |
+
'id': task['id'],
|
| 1022 |
+
'status': task['status'],
|
| 1023 |
+
'progress': task.get('progress', 0),
|
| 1024 |
+
'total': task.get('total', 0),
|
| 1025 |
+
'created_at': task['created_at'],
|
| 1026 |
+
'updated_at': task['updated_at']
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
# 如果任务完成,包含结果
|
| 1030 |
+
if task['status'] == TASK_COMPLETED and 'result' in task:
|
| 1031 |
+
status['result'] = task['result']
|
| 1032 |
+
|
| 1033 |
+
# 如果任务失败,包含错误信息
|
| 1034 |
+
if task['status'] == TASK_FAILED and 'error' in task:
|
| 1035 |
+
status['error'] = task['error']
|
| 1036 |
+
|
| 1037 |
+
return custom_jsonify(status)
|
| 1038 |
+
|
| 1039 |
+
|
| 1040 |
+
@app.route('/api/cancel_scan/<task_id>', methods=['POST'])
|
| 1041 |
+
def cancel_scan(task_id):
|
| 1042 |
+
"""取消扫描任务"""
|
| 1043 |
+
with task_lock:
|
| 1044 |
+
if task_id not in scan_tasks:
|
| 1045 |
+
return jsonify({'error': '找不到指定的扫描任务'}), 404
|
| 1046 |
+
|
| 1047 |
+
task = scan_tasks[task_id]
|
| 1048 |
+
|
| 1049 |
+
if task['status'] in [TASK_COMPLETED, TASK_FAILED]:
|
| 1050 |
+
return jsonify({'message': '任务已完成或失败,无法取消'})
|
| 1051 |
+
|
| 1052 |
+
# 更新状态为失败
|
| 1053 |
+
task['status'] = TASK_FAILED
|
| 1054 |
+
task['error'] = '用户取消任务'
|
| 1055 |
+
task['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1056 |
+
|
| 1057 |
+
return jsonify({'message': '任务已取消'})
|
| 1058 |
+
|
| 1059 |
+
|
| 1060 |
+
@app.route('/api/index_stocks', methods=['GET'])
|
| 1061 |
+
def get_index_stocks():
|
| 1062 |
+
"""获取指数成分股"""
|
| 1063 |
+
try:
|
| 1064 |
+
import akshare as ak
|
| 1065 |
+
index_code = request.args.get('index_code', '000300') # 默认沪深300
|
| 1066 |
+
|
| 1067 |
+
# 获取指数成分股
|
| 1068 |
+
app.logger.info(f"获取指数 {index_code} 成分股")
|
| 1069 |
+
if index_code == '000300':
|
| 1070 |
+
# 沪深300成分股
|
| 1071 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000300")
|
| 1072 |
+
elif index_code == '000905':
|
| 1073 |
+
# 中证500成分股
|
| 1074 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000905")
|
| 1075 |
+
elif index_code == '000852':
|
| 1076 |
+
# 中证1000成分股
|
| 1077 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000852")
|
| 1078 |
+
elif index_code == '000001':
|
| 1079 |
+
# 上证指数
|
| 1080 |
+
stocks = ak.index_stock_cons_weight_csindex(symbol="000001")
|
| 1081 |
+
else:
|
| 1082 |
+
return jsonify({'error': '不支持的指数代码'}), 400
|
| 1083 |
+
|
| 1084 |
+
# 提取股票代码列表
|
| 1085 |
+
stock_list = stocks['成分券代码'].tolist() if '成分券代码' in stocks.columns else []
|
| 1086 |
+
app.logger.info(f"找到 {len(stock_list)} 只成分股")
|
| 1087 |
+
|
| 1088 |
+
return jsonify({'stock_list': stock_list})
|
| 1089 |
+
except Exception as e:
|
| 1090 |
+
app.logger.error(f"获取指数成分股时出错: {traceback.format_exc()}")
|
| 1091 |
+
return jsonify({'error': str(e)}), 500
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
@app.route('/api/industry_stocks', methods=['GET'])
|
| 1095 |
+
def get_industry_stocks():
|
| 1096 |
+
"""获取行业成分股"""
|
| 1097 |
+
try:
|
| 1098 |
+
import akshare as ak
|
| 1099 |
+
industry = request.args.get('industry', '')
|
| 1100 |
+
|
| 1101 |
+
if not industry:
|
| 1102 |
+
return jsonify({'error': '请提供行业名称'}), 400
|
| 1103 |
+
|
| 1104 |
+
# 获取行业成分股
|
| 1105 |
+
app.logger.info(f"获取 {industry} 行业成分股")
|
| 1106 |
+
stocks = ak.stock_board_industry_cons_em(symbol=industry)
|
| 1107 |
+
|
| 1108 |
+
# 提取股票代码列表
|
| 1109 |
+
stock_list = stocks['代码'].tolist() if '代码' in stocks.columns else []
|
| 1110 |
+
app.logger.info(f"找到 {len(stock_list)} 只 {industry} 行业股票")
|
| 1111 |
+
|
| 1112 |
+
return jsonify({'stock_list': stock_list})
|
| 1113 |
+
except Exception as e:
|
| 1114 |
+
app.logger.error(f"获取行业成分股时出错: {traceback.format_exc()}")
|
| 1115 |
+
return jsonify({'error': str(e)}), 500
|
| 1116 |
+
|
| 1117 |
+
|
| 1118 |
+
# 添加到web_server.py
|
| 1119 |
+
def clean_old_tasks():
|
| 1120 |
+
"""清理旧的扫描任务"""
|
| 1121 |
+
with task_lock:
|
| 1122 |
+
now = datetime.now()
|
| 1123 |
+
to_delete = []
|
| 1124 |
+
|
| 1125 |
+
for task_id, task in scan_tasks.items():
|
| 1126 |
+
# 解析更新时间
|
| 1127 |
+
try:
|
| 1128 |
+
updated_at = datetime.strptime(task['updated_at'], '%Y-%m-%d %H:%M:%S')
|
| 1129 |
+
# 如果任务完成或失败且超过1小时,或者任务状态异常且超过3小时,清理它
|
| 1130 |
+
if ((task['status'] in [TASK_COMPLETED, TASK_FAILED] and
|
| 1131 |
+
(now - updated_at).total_seconds() > 3600) or
|
| 1132 |
+
((now - updated_at).total_seconds() > 10800)):
|
| 1133 |
+
to_delete.append(task_id)
|
| 1134 |
+
except:
|
| 1135 |
+
# 日期解析错误,添加到删除列表
|
| 1136 |
+
to_delete.append(task_id)
|
| 1137 |
+
|
| 1138 |
+
# 删除旧任务
|
| 1139 |
+
for task_id in to_delete:
|
| 1140 |
+
del scan_tasks[task_id]
|
| 1141 |
+
|
| 1142 |
+
return len(to_delete)
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
# 修改 run_task_cleaner 函数,使其每 5 分钟运行一次并在 16:30 左右清理所有缓存
|
| 1146 |
+
def run_task_cleaner():
|
| 1147 |
+
"""定期运行任务清理,并在每天 16:30 左右清理所有缓存"""
|
| 1148 |
+
while True:
|
| 1149 |
+
try:
|
| 1150 |
+
now = datetime.now()
|
| 1151 |
+
# 判断是否在收盘时间附近(16:25-16:35)
|
| 1152 |
+
is_market_close_time = (now.hour == 16 and 25 <= now.minute <= 35)
|
| 1153 |
+
|
| 1154 |
+
cleaned = clean_old_tasks()
|
| 1155 |
+
|
| 1156 |
+
# 如果是收盘时间,清理所有缓存
|
| 1157 |
+
if is_market_close_time:
|
| 1158 |
+
# 清理分析器的数据缓存
|
| 1159 |
+
analyzer.data_cache.clear()
|
| 1160 |
+
|
| 1161 |
+
# 清理 Flask 缓存
|
| 1162 |
+
cache.clear()
|
| 1163 |
+
|
| 1164 |
+
# 清理任务存储
|
| 1165 |
+
with task_lock:
|
| 1166 |
+
for task_type in tasks:
|
| 1167 |
+
task_store = tasks[task_type]
|
| 1168 |
+
completed_tasks = [task_id for task_id, task in task_store.items()
|
| 1169 |
+
if task['status'] == TASK_COMPLETED]
|
| 1170 |
+
for task_id in completed_tasks:
|
| 1171 |
+
del task_store[task_id]
|
| 1172 |
+
|
| 1173 |
+
app.logger.info("市场收盘时间检测到,已清理所有缓存数据")
|
| 1174 |
+
|
| 1175 |
+
if cleaned > 0:
|
| 1176 |
+
app.logger.info(f"清理了 {cleaned} 个旧的扫描任务")
|
| 1177 |
+
except Exception as e:
|
| 1178 |
+
app.logger.error(f"任务清理出错: {str(e)}")
|
| 1179 |
+
|
| 1180 |
+
# 每 5 分钟运行一次,而不是每小时
|
| 1181 |
+
time.sleep(600)
|
| 1182 |
+
|
| 1183 |
+
|
| 1184 |
+
# 基本面分析路由
|
| 1185 |
+
@app.route('/api/fundamental_analysis', methods=['POST'])
|
| 1186 |
+
def api_fundamental_analysis():
|
| 1187 |
+
try:
|
| 1188 |
+
data = request.json
|
| 1189 |
+
stock_code = data.get('stock_code')
|
| 1190 |
+
|
| 1191 |
+
if not stock_code:
|
| 1192 |
+
return jsonify({'error': '请提供股票代码'}), 400
|
| 1193 |
+
|
| 1194 |
+
# 获取基本面分析结果
|
| 1195 |
+
result = fundamental_analyzer.calculate_fundamental_score(stock_code)
|
| 1196 |
+
|
| 1197 |
+
return custom_jsonify(result)
|
| 1198 |
+
except Exception as e:
|
| 1199 |
+
app.logger.error(f"基本面分析出错: {traceback.format_exc()}")
|
| 1200 |
+
return jsonify({'error': str(e)}), 500
|
| 1201 |
+
|
| 1202 |
+
|
| 1203 |
+
# 资金流向分析路由
|
| 1204 |
+
# Add to web_server.py
|
| 1205 |
+
|
| 1206 |
+
# 获取概念资金流向的API端点
|
| 1207 |
+
@app.route('/api/concept_fund_flow', methods=['GET'])
|
| 1208 |
+
def api_concept_fund_flow():
|
| 1209 |
+
try:
|
| 1210 |
+
period = request.args.get('period', '10日排行') # Default to 10-day ranking
|
| 1211 |
+
|
| 1212 |
+
# Get concept fund flow data
|
| 1213 |
+
result = capital_flow_analyzer.get_concept_fund_flow(period)
|
| 1214 |
+
|
| 1215 |
+
return custom_jsonify(result)
|
| 1216 |
+
except Exception as e:
|
| 1217 |
+
app.logger.error(f"Error getting concept fund flow: {traceback.format_exc()}")
|
| 1218 |
+
return jsonify({'error': str(e)}), 500
|
| 1219 |
+
|
| 1220 |
+
|
| 1221 |
+
# 获取个股资金流向排名的API端点
|
| 1222 |
+
@app.route('/api/individual_fund_flow_rank', methods=['GET'])
|
| 1223 |
+
def api_individual_fund_flow_rank():
|
| 1224 |
+
try:
|
| 1225 |
+
period = request.args.get('period', '10日') # Default to today
|
| 1226 |
+
|
| 1227 |
+
# Get individual fund flow ranking data
|
| 1228 |
+
result = capital_flow_analyzer.get_individual_fund_flow_rank(period)
|
| 1229 |
+
|
| 1230 |
+
return custom_jsonify(result)
|
| 1231 |
+
except Exception as e:
|
| 1232 |
+
app.logger.error(f"Error getting individual fund flow ranking: {traceback.format_exc()}")
|
| 1233 |
+
return jsonify({'error': str(e)}), 500
|
| 1234 |
+
|
| 1235 |
+
|
| 1236 |
+
# 获取个股资金流向的API端点
|
| 1237 |
+
@app.route('/api/individual_fund_flow', methods=['GET'])
|
| 1238 |
+
def api_individual_fund_flow():
|
| 1239 |
+
try:
|
| 1240 |
+
stock_code = request.args.get('stock_code')
|
| 1241 |
+
market_type = request.args.get('market_type', '') # Auto-detect if not provided
|
| 1242 |
+
re_date = request.args.get('period-select')
|
| 1243 |
+
|
| 1244 |
+
if not stock_code:
|
| 1245 |
+
return jsonify({'error': 'Stock code is required'}), 400
|
| 1246 |
+
|
| 1247 |
+
# Get individual fund flow data
|
| 1248 |
+
result = capital_flow_analyzer.get_individual_fund_flow(stock_code, market_type, re_date)
|
| 1249 |
+
return custom_jsonify(result)
|
| 1250 |
+
except Exception as e:
|
| 1251 |
+
app.logger.error(f"Error getting individual fund flow: {traceback.format_exc()}")
|
| 1252 |
+
return jsonify({'error': str(e)}), 500
|
| 1253 |
+
|
| 1254 |
+
|
| 1255 |
+
# 获取板块内股票的API端点
|
| 1256 |
+
@app.route('/api/sector_stocks', methods=['GET'])
|
| 1257 |
+
def api_sector_stocks():
|
| 1258 |
+
try:
|
| 1259 |
+
sector = request.args.get('sector')
|
| 1260 |
+
|
| 1261 |
+
if not sector:
|
| 1262 |
+
return jsonify({'error': 'Sector name is required'}), 400
|
| 1263 |
+
|
| 1264 |
+
# Get sector stocks data
|
| 1265 |
+
result = capital_flow_analyzer.get_sector_stocks(sector)
|
| 1266 |
+
|
| 1267 |
+
return custom_jsonify(result)
|
| 1268 |
+
except Exception as e:
|
| 1269 |
+
app.logger.error(f"Error getting sector stocks: {traceback.format_exc()}")
|
| 1270 |
+
return jsonify({'error': str(e)}), 500
|
| 1271 |
+
|
| 1272 |
+
|
| 1273 |
+
# Update the existing capital flow API endpoint
|
| 1274 |
+
@app.route('/api/capital_flow', methods=['POST'])
|
| 1275 |
+
def api_capital_flow():
|
| 1276 |
+
try:
|
| 1277 |
+
data = request.json
|
| 1278 |
+
stock_code = data.get('stock_code')
|
| 1279 |
+
market_type = data.get('market_type', '') # Auto-detect if not provided
|
| 1280 |
+
|
| 1281 |
+
if not stock_code:
|
| 1282 |
+
return jsonify({'error': 'Stock code is required'}), 400
|
| 1283 |
+
|
| 1284 |
+
# Calculate capital flow score
|
| 1285 |
+
result = capital_flow_analyzer.calculate_capital_flow_score(stock_code, market_type)
|
| 1286 |
+
|
| 1287 |
+
return custom_jsonify(result)
|
| 1288 |
+
except Exception as e:
|
| 1289 |
+
app.logger.error(f"Error calculating capital flow score: {traceback.format_exc()}")
|
| 1290 |
+
return jsonify({'error': str(e)}), 500
|
| 1291 |
+
|
| 1292 |
+
|
| 1293 |
+
# 情景预测路由
|
| 1294 |
+
@app.route('/api/scenario_predict', methods=['POST'])
|
| 1295 |
+
def api_scenario_predict():
|
| 1296 |
+
try:
|
| 1297 |
+
data = request.json
|
| 1298 |
+
stock_code = data.get('stock_code')
|
| 1299 |
+
market_type = data.get('market_type', 'A')
|
| 1300 |
+
days = data.get('days', 60)
|
| 1301 |
+
|
| 1302 |
+
if not stock_code:
|
| 1303 |
+
return jsonify({'error': '请提供股票代码'}), 400
|
| 1304 |
+
|
| 1305 |
+
# 获取情景预测结果
|
| 1306 |
+
result = scenario_predictor.generate_scenarios(stock_code, market_type, days)
|
| 1307 |
+
|
| 1308 |
+
return custom_jsonify(result)
|
| 1309 |
+
except Exception as e:
|
| 1310 |
+
app.logger.error(f"情景预测出错: {traceback.format_exc()}")
|
| 1311 |
+
return jsonify({'error': str(e)}), 500
|
| 1312 |
+
|
| 1313 |
+
|
| 1314 |
+
# 智能问答路由
|
| 1315 |
+
@app.route('/api/qa', methods=['POST'])
|
| 1316 |
+
def api_qa():
|
| 1317 |
+
try:
|
| 1318 |
+
data = request.json
|
| 1319 |
+
stock_code = data.get('stock_code')
|
| 1320 |
+
question = data.get('question')
|
| 1321 |
+
market_type = data.get('market_type', 'A')
|
| 1322 |
+
|
| 1323 |
+
if not stock_code or not question:
|
| 1324 |
+
return jsonify({'error': '请提供股票代码和问题'}), 400
|
| 1325 |
+
|
| 1326 |
+
# 获取智能问答结果
|
| 1327 |
+
result = stock_qa.answer_question(stock_code, question, market_type)
|
| 1328 |
+
|
| 1329 |
+
return custom_jsonify(result)
|
| 1330 |
+
except Exception as e:
|
| 1331 |
+
app.logger.error(f"智能问答出错: {traceback.format_exc()}")
|
| 1332 |
+
return jsonify({'error': str(e)}), 500
|
| 1333 |
+
|
| 1334 |
+
|
| 1335 |
+
# 风险分析路由
|
| 1336 |
+
@app.route('/api/risk_analysis', methods=['POST'])
|
| 1337 |
+
def api_risk_analysis():
|
| 1338 |
+
try:
|
| 1339 |
+
data = request.json
|
| 1340 |
+
stock_code = data.get('stock_code')
|
| 1341 |
+
market_type = data.get('market_type', 'A')
|
| 1342 |
+
|
| 1343 |
+
if not stock_code:
|
| 1344 |
+
return jsonify({'error': '请提供股票代码'}), 400
|
| 1345 |
+
|
| 1346 |
+
# 获取风险分析结果
|
| 1347 |
+
result = risk_monitor.analyze_stock_risk(stock_code, market_type)
|
| 1348 |
+
|
| 1349 |
+
return custom_jsonify(result)
|
| 1350 |
+
except Exception as e:
|
| 1351 |
+
app.logger.error(f"风险分析出错: {traceback.format_exc()}")
|
| 1352 |
+
return jsonify({'error': str(e)}), 500
|
| 1353 |
+
|
| 1354 |
+
|
| 1355 |
+
# 投资组合风险分析路由
|
| 1356 |
+
@app.route('/api/portfolio_risk', methods=['POST'])
|
| 1357 |
+
def api_portfolio_risk():
|
| 1358 |
+
try:
|
| 1359 |
+
data = request.json
|
| 1360 |
+
portfolio = data.get('portfolio', [])
|
| 1361 |
+
|
| 1362 |
+
if not portfolio:
|
| 1363 |
+
return jsonify({'error': '请提供投资组合'}), 400
|
| 1364 |
+
|
| 1365 |
+
# 获取投资组合风险分析结果
|
| 1366 |
+
result = risk_monitor.analyze_portfolio_risk(portfolio)
|
| 1367 |
+
|
| 1368 |
+
return custom_jsonify(result)
|
| 1369 |
+
except Exception as e:
|
| 1370 |
+
app.logger.error(f"投资组合风险分析出错: {traceback.format_exc()}")
|
| 1371 |
+
return jsonify({'error': str(e)}), 500
|
| 1372 |
+
|
| 1373 |
+
|
| 1374 |
+
# 指数分析路由
|
| 1375 |
+
@app.route('/api/index_analysis', methods=['GET'])
|
| 1376 |
+
def api_index_analysis():
|
| 1377 |
+
try:
|
| 1378 |
+
index_code = request.args.get('index_code')
|
| 1379 |
+
limit = int(request.args.get('limit', 30))
|
| 1380 |
+
|
| 1381 |
+
if not index_code:
|
| 1382 |
+
return jsonify({'error': '请提供指数代码'}), 400
|
| 1383 |
+
|
| 1384 |
+
# 获取指数分析结果
|
| 1385 |
+
result = index_industry_analyzer.analyze_index(index_code, limit)
|
| 1386 |
+
|
| 1387 |
+
return custom_jsonify(result)
|
| 1388 |
+
except Exception as e:
|
| 1389 |
+
app.logger.error(f"指数分析出错: {traceback.format_exc()}")
|
| 1390 |
+
return jsonify({'error': str(e)}), 500
|
| 1391 |
+
|
| 1392 |
+
|
| 1393 |
+
# 行业分析路由
|
| 1394 |
+
@app.route('/api/industry_analysis', methods=['GET'])
|
| 1395 |
+
def api_industry_analysis():
|
| 1396 |
+
try:
|
| 1397 |
+
industry = request.args.get('industry')
|
| 1398 |
+
limit = int(request.args.get('limit', 30))
|
| 1399 |
+
|
| 1400 |
+
if not industry:
|
| 1401 |
+
return jsonify({'error': '请提供行业名称'}), 400
|
| 1402 |
+
|
| 1403 |
+
# 获取行业分析结果
|
| 1404 |
+
result = index_industry_analyzer.analyze_industry(industry, limit)
|
| 1405 |
+
|
| 1406 |
+
return custom_jsonify(result)
|
| 1407 |
+
except Exception as e:
|
| 1408 |
+
app.logger.error(f"行业分析出错: {traceback.format_exc()}")
|
| 1409 |
+
return jsonify({'error': str(e)}), 500
|
| 1410 |
+
|
| 1411 |
+
|
| 1412 |
+
@app.route('/api/industry_fund_flow', methods=['GET'])
|
| 1413 |
+
def api_industry_fund_flow():
|
| 1414 |
+
"""获取行业资金流向数据"""
|
| 1415 |
+
try:
|
| 1416 |
+
symbol = request.args.get('symbol', '即时')
|
| 1417 |
+
|
| 1418 |
+
result = industry_analyzer.get_industry_fund_flow(symbol)
|
| 1419 |
+
|
| 1420 |
+
return custom_jsonify(result)
|
| 1421 |
+
except Exception as e:
|
| 1422 |
+
app.logger.error(f"获取行业资金流向数据出错: {traceback.format_exc()}")
|
| 1423 |
+
return jsonify({'error': str(e)}), 500
|
| 1424 |
+
|
| 1425 |
+
|
| 1426 |
+
@app.route('/api/industry_detail', methods=['GET'])
|
| 1427 |
+
def api_industry_detail():
|
| 1428 |
+
"""获取行业详细信息"""
|
| 1429 |
+
try:
|
| 1430 |
+
industry = request.args.get('industry')
|
| 1431 |
+
|
| 1432 |
+
if not industry:
|
| 1433 |
+
return jsonify({'error': '请提供行业名称'}), 400
|
| 1434 |
+
|
| 1435 |
+
result = industry_analyzer.get_industry_detail(industry)
|
| 1436 |
+
|
| 1437 |
+
app.logger.info(f"返回前 (result):{result}")
|
| 1438 |
+
if not result:
|
| 1439 |
+
return jsonify({'error': f'未找到行业 {industry} 的详细信息'}), 404
|
| 1440 |
+
|
| 1441 |
+
return custom_jsonify(result)
|
| 1442 |
+
except Exception as e:
|
| 1443 |
+
app.logger.error(f"获取行业详细信息出错: {traceback.format_exc()}")
|
| 1444 |
+
return jsonify({'error': str(e)}), 500
|
| 1445 |
+
|
| 1446 |
+
|
| 1447 |
+
# 行业比较路由
|
| 1448 |
+
@app.route('/api/industry_compare', methods=['GET'])
|
| 1449 |
+
def api_industry_compare():
|
| 1450 |
+
try:
|
| 1451 |
+
limit = int(request.args.get('limit', 10))
|
| 1452 |
+
|
| 1453 |
+
# 获取行业比较结果
|
| 1454 |
+
result = index_industry_analyzer.compare_industries(limit)
|
| 1455 |
+
|
| 1456 |
+
return custom_jsonify(result)
|
| 1457 |
+
except Exception as e:
|
| 1458 |
+
app.logger.error(f"行业比较出错: {traceback.format_exc()}")
|
| 1459 |
+
return jsonify({'error': str(e)}), 500
|
| 1460 |
+
|
| 1461 |
+
|
| 1462 |
+
# 保存股票分析结果到数据库
|
| 1463 |
+
def save_analysis_result(stock_code, market_type, result):
|
| 1464 |
+
"""保存分析结果到数据库"""
|
| 1465 |
+
if not USE_DATABASE:
|
| 1466 |
+
return
|
| 1467 |
+
|
| 1468 |
+
try:
|
| 1469 |
+
session = get_session()
|
| 1470 |
+
|
| 1471 |
+
# 创建新的分析结果记录
|
| 1472 |
+
analysis = AnalysisResult(
|
| 1473 |
+
stock_code=stock_code,
|
| 1474 |
+
market_type=market_type,
|
| 1475 |
+
score=result.get('scores', {}).get('total', 0),
|
| 1476 |
+
recommendation=result.get('recommendation', {}).get('action', ''),
|
| 1477 |
+
technical_data=result.get('technical_analysis', {}),
|
| 1478 |
+
fundamental_data=result.get('fundamental_data', {}),
|
| 1479 |
+
capital_flow_data=result.get('capital_flow_data', {}),
|
| 1480 |
+
ai_analysis=result.get('ai_analysis', '')
|
| 1481 |
+
)
|
| 1482 |
+
|
| 1483 |
+
session.add(analysis)
|
| 1484 |
+
session.commit()
|
| 1485 |
+
|
| 1486 |
+
except Exception as e:
|
| 1487 |
+
app.logger.error(f"保存分析结果到数据库时出错: {str(e)}")
|
| 1488 |
+
if session:
|
| 1489 |
+
session.rollback()
|
| 1490 |
+
finally:
|
| 1491 |
+
if session:
|
| 1492 |
+
session.close()
|
| 1493 |
+
|
| 1494 |
+
|
| 1495 |
+
# 从数据库获取历史分析结果
|
| 1496 |
+
@app.route('/api/history_analysis', methods=['GET'])
|
| 1497 |
+
def get_history_analysis():
|
| 1498 |
+
"""获取股票的历史分析结果"""
|
| 1499 |
+
if not USE_DATABASE:
|
| 1500 |
+
return jsonify({'error': '数据库功能未启用'}), 400
|
| 1501 |
+
|
| 1502 |
+
stock_code = request.args.get('stock_code')
|
| 1503 |
+
limit = int(request.args.get('limit', 10))
|
| 1504 |
+
|
| 1505 |
+
if not stock_code:
|
| 1506 |
+
return jsonify({'error': '请提供股票代码'}), 400
|
| 1507 |
+
|
| 1508 |
+
try:
|
| 1509 |
+
session = get_session()
|
| 1510 |
+
|
| 1511 |
+
# 查询历史分析结果
|
| 1512 |
+
results = session.query(AnalysisResult) \
|
| 1513 |
+
.filter(AnalysisResult.stock_code == stock_code) \
|
| 1514 |
+
.order_by(AnalysisResult.analysis_date.desc()) \
|
| 1515 |
+
.limit(limit) \
|
| 1516 |
+
.all()
|
| 1517 |
+
|
| 1518 |
+
# 转换为字典列表
|
| 1519 |
+
history = [result.to_dict() for result in results]
|
| 1520 |
+
|
| 1521 |
+
return jsonify({'history': history})
|
| 1522 |
+
|
| 1523 |
+
except Exception as e:
|
| 1524 |
+
app.logger.error(f"获取历史分析结果时出错: {str(e)}")
|
| 1525 |
+
return jsonify({'error': str(e)}), 500
|
| 1526 |
+
finally:
|
| 1527 |
+
if session:
|
| 1528 |
+
session.close()
|
| 1529 |
+
|
| 1530 |
+
|
| 1531 |
+
# 在应用启动时启动清理线程(保持原有代码不变)
|
| 1532 |
+
cleaner_thread = threading.Thread(target=run_task_cleaner)
|
| 1533 |
+
cleaner_thread.daemon = True
|
| 1534 |
+
cleaner_thread.start()
|
| 1535 |
+
|
| 1536 |
+
if __name__ == '__main__':
|
| 1537 |
+
# 将 host 设置为 '0.0.0.0' 使其支持所有网络接口访问
|
| 1538 |
+
app.run(host='0.0.0.0', port=8888, debug=False)
|