增加复盘与计划模块功能

master^2
Lxy 1 month ago
parent 0ade46805a
commit 94d06fea27

@ -0,0 +1,273 @@
# 用户权限系统使用指南
## 📋 系统概述
期货智析系统现已集成完整的用户权限管理系统,所有页面需要进行登录才能访问。系统支持两种用户角色:
- **管理员 (admin)**: 可以访问品种分析界面和系统管理界面
- **普通用户 (user)**: 只能访问品种分析界面
## 🔐 默认账户
系统首次启动时会自动创建默认管理员账户:
| 字段 | 值 |
|------|------|
| **用户名** | `lxy_root` |
| **密码** | `admin123` |
| **角色** | 管理员 (admin) |
| **邮箱** | `admin@system.local` |
⚠️ **重要**: 首次登录后请立即修改默认密码!
## 🚀 使用流程
### 1. 访问系统
打开浏览器访问:`http://localhost:9600`
系统会自动重定向到登录页面。
### 2. 登录
1. 输入用户名和密码
2. 点击"登录"按钮
3. 系统验证成功后会根据用户角色进行跳转
### 3. 角色跳转逻辑
#### 普通用户
- 登录后直接进入 **品种分析** 界面
- 访问地址:`http://localhost:9600/futures-analysis`
#### 管理员用户
- 登录后进入 **角色选择** 页面
- 可选择进入:
- **品种分析** - 智能期货分析工具
- **系统管理** - 后台管理配置界面
### 4. 退出登录
在任意页面点击"退出登录"按钮,或在角色选择页面点击退出。
## 📁 文件结构
```
app/
├── user_models.py # 用户数据库模型
├── auth_service.py # 认证服务(密码加密、会话管理)
├── api/
│ └── auth.py # 认证API路由
├── static/
│ ├── login.html # 登录页面
│ └── role_select.html # 角色选择页面
└── main.py # 主入口(已集成认证)
```
## 🔧 API接口
### 1. 用户登录
**POST** `/api/v1/auth/login`
**请求体:**
```json
{
"username": "lxy_root",
"password": "admin123"
}
```
**响应:**
```json
{
"success": true,
"token": "jr1xNX4egsLRStvVYLnkJj8uruB902VwCG4z3Wuy6dc",
"user": {
"id": 1,
"username": "lxy_root",
"role": "admin",
"email": "admin@system.local"
},
"message": "登录成功"
}
```
### 2. 验证令牌
**GET** `/api/v1/auth/verify`
**请求头:**
```
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"user": {
"id": 1,
"username": "lxy_root",
"role": "admin",
"email": "admin@system.local",
"last_login": "2026-05-24T15:30:00"
}
}
```
### 3. 获取当前用户信息
**GET** `/api/v1/auth/me`
**请求头:**
```
Authorization: Bearer <token>
```
### 4. 用户登出
**POST** `/api/v1/auth/logout`
**请求头:**
```
Authorization: Bearer <token>
```
## 🗄️ 数据库表
系统创建了两个新表:
### 1. users 表 - 用户信息
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| username | VARCHAR(50) | 用户名(唯一) |
| password_hash | VARCHAR(255) | 密码哈希 |
| email | VARCHAR(100) | 邮箱 |
| role | VARCHAR(20) | 角色 (admin/user) |
| is_active | BOOLEAN | 是否启用 |
| created_at | DATETIME | 创建时间 |
| last_login | DATETIME | 最后登录时间 |
### 2. sessions 表 - 会话管理
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| user_id | INTEGER | 用户ID |
| token | VARCHAR(255) | 会话令牌(唯一) |
| created_at | DATETIME | 创建时间 |
| expires_at | DATETIME | 过期时间 |
| is_valid | BOOLEAN | 是否有效 |
## 🔒 安全特性
1. **密码加密**: 使用 SHA-256 + 随机盐值加密存储
2. **会话管理**: 使用安全令牌24小时自动过期
3. **权限控制**: 基于角色的访问控制 (RBAC)
4. **自动清理**: 过期会话自动标记为无效
## 👥 用户管理
### 创建新用户
可以通过Python脚本或直接操作数据库创建用户
```python
from app.database import SessionLocal
from app import auth_service
db = SessionLocal()
try:
auth_service.create_user(
db=db,
username='new_user',
password='secure_password',
email='user@example.com',
role='user' # 或 'admin'
)
finally:
db.close()
```
### 修改用户角色
```python
from app.database import SessionLocal
from app.user_models import User
db = SessionLocal()
try:
user = db.query(User).filter(User.username == 'username').first()
user.role = 'admin' # 或 'user'
db.commit()
finally:
db.close()
```
### 禁用用户
```python
user.is_active = False
db.commit()
```
## 🎯 页面访问控制
| 页面 | 需要登录 | 需要角色 |
|------|---------|---------|
| `/login` | ❌ | ❌ |
| `/futures-analysis` | ✅ | 任意 |
| `/role-select` | ✅ | admin |
| `/ui` | ✅ | admin |
| `/ai-config` | ✅ | 任意 |
| `/api/v1/*` | 部分接口 | 视接口而定 |
## 🐛 故障排查
### 1. 登录后无法访问页面
- 检查浏览器控制台是否有错误
- 确认localStorage中保存了auth_token
- 尝试清除浏览器缓存后重新登录
### 2. 令牌过期
- 会话令牌24小时过期
- 过期后需要重新登录
- 可在代码中修改 `expires_hours` 参数
### 3. 忘记管理员密码
可以通过数据库重置:
```python
from app.database import SessionLocal
from app import auth_service
db = SessionLocal()
try:
from app.user_models import User
admin = db.query(User).filter(User.username == 'lxy_root').first()
admin.password_hash = auth_service.hash_password('new_password')
db.commit()
print("密码已重置")
finally:
db.close()
```
## 📝 最佳实践
1. ✅ 首次登录后修改默认密码
2. ✅ 定期清理过期会话
3. ✅ 为不同用户分配最小权限原则
4. ✅ 定期检查用户登录日志
5. ✅ 备份用户数据库表
## 🔗 相关文档
- [部署指南](./DEPLOY.md)
- [系统架构](./DESIGN.md)
- [API文档](http://localhost:9600/docs)

@ -36,5 +36,9 @@ def init_analysis_db():
# 确保导入所有模型类,使其注册到 AnalysisBase
from app import analysis_models
# 直接导入 analysis_models 模块中的所有类
from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings, AIAnalysisCache, ReviewDate, SymbolRanking, TradingPlan
from app.analysis_models import (
FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings,
AIAnalysisCache, ReviewDate, SymbolRanking, TradingPlan,
SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2,
)
AnalysisBase.metadata.create_all(bind=analysis_engine)

@ -154,3 +154,120 @@ class TradingPlan(AnalysisBase):
def __repr__(self):
return f"<TradingPlan {self.symbol} {self.plan_type} score={self.score}>"
# ==================== V2 复盘计划模型 ====================
class SymbolScoreV2(AnalysisBase):
"""V2 品种多维度评分表"""
__tablename__ = "symbol_scores_v2"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
close_price = Column(Float, nullable=True, comment="收盘价")
prev_close = Column(Float, nullable=True, comment="昨收价")
high_price = Column(Float, nullable=True, comment="当日最高")
low_price = Column(Float, nullable=True, comment="当日最低")
volume = Column(Float, nullable=True, comment="当日成交量")
avg_volume_5 = Column(Float, nullable=True, comment="近5日均量")
# 5维度评分
amplitude_score = Column(Float, nullable=True, comment="振幅得分 0-100")
volume_score = Column(Float, nullable=True, comment="量能得分 0-100")
change_score = Column(Float, nullable=True, comment="涨跌幅得分 0-100")
trend_score = Column(Float, nullable=True, comment="趋势得分 -100~100")
activity_score = Column(Float, nullable=True, comment="活跃度得分 0-100")
# 综合
composite_score = Column(Float, nullable=True, comment="综合评分 0-100")
# 原始数据
amplitude_pct = Column(Float, nullable=True, comment="振幅百分比")
change_pct = Column(Float, nullable=True, comment="涨跌幅百分比")
volume_ratio = Column(Float, nullable=True, comment="量比")
# 趋势明细
trend_60m = Column(Float, nullable=True, comment="60分钟趋势分")
trend_15m = Column(Float, nullable=True, comment="15分钟趋势分")
trend_5m = Column(Float, nullable=True, comment="5分钟趋势分")
# 方向标签
direction = Column(String(32), nullable=True, comment="方向标签: 多头共振/空头共振/偏多震荡/偏空震荡/多空交织")
direction_tag = Column(String(16), nullable=True, comment="方向标签简写: 强多/偏多/震荡/偏空/强空")
category = Column(String(16), nullable=True, comment="分类: green(交易机会)/yellow(重点关注)/red(规避)")
# 关键点位
pivot = Column(Float, nullable=True, comment="枢轴点")
r1 = Column(Float, nullable=True, comment="阻力位1")
r2 = Column(Float, nullable=True, comment="阻力位2")
s1 = Column(Float, nullable=True, comment="支撑位1")
s2 = Column(Float, nullable=True, comment="支撑位2")
# 排名
rank = Column(Integer, nullable=True, comment="综合排名")
def __repr__(self):
return f"<SymbolScoreV2 {self.symbol} score={self.composite_score}>"
class TradingPlanV2(AnalysisBase):
"""V2 交易计划表"""
__tablename__ = "trading_plans_v2"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
direction = Column(String(16), nullable=False, comment="方向: long/short")
composite_score = Column(Float, nullable=True, comment="综合评分")
# 交易计划
entry_low = Column(Float, nullable=True, comment="入场区间下限")
entry_high = Column(Float, nullable=True, comment="入场区间上限")
stop_loss = Column(Float, nullable=True, comment="止损位")
target1 = Column(Float, nullable=True, comment="目标位1")
target2 = Column(Float, nullable=True, comment="目标位2")
trigger = Column(String(128), nullable=True, comment="触发条件")
# 评分明细
amplitude_score = Column(Float, nullable=True, comment="振幅得分")
volume_score = Column(Float, nullable=True, comment="量能得分")
trend_score = Column(Float, nullable=True, comment="趋势得分")
activity_score = Column(Float, nullable=True, comment="活跃度得分")
# 分类
category = Column(String(16), nullable=True, comment="分类: green/yellow/red")
def __repr__(self):
return f"<TradingPlanV2 {self.symbol} {self.direction} score={self.composite_score}>"
class SectorHeat(AnalysisBase):
"""板块热度表"""
__tablename__ = "sector_heat"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
sector_name = Column(String(32), nullable=False, comment="板块名称")
avg_score = Column(Float, nullable=True, comment="板块均分")
avg_trend = Column(Float, nullable=True, comment="平均趋势分")
direction = Column(String(16), nullable=True, comment="方向: 多头/空头/震荡")
heat_level = Column(Integer, nullable=True, comment="热度等级 0-3")
leader_symbol = Column(String(32), nullable=True, comment="龙头品种代码")
leader_score = Column(Float, nullable=True, comment="龙头品种评分")
members = Column(JSON, nullable=True, comment="板块成员 [{symbol, name, score}]")
def __repr__(self):
return f"<SectorHeat {self.sector_name} avg={self.avg_score}>"
class ReviewPlanV2(AnalysisBase):
"""V2 复盘计划总表 - 存储每次生成的完整报告元数据"""
__tablename__ = "review_plans_v2"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date = Column(String(16), nullable=False, unique=True, index=True, comment="复盘日期 YYYY-MM-DD")
week_day = Column(String(8), nullable=True, comment="星期")
data_basis = Column(String(128), nullable=True, comment="数据基准说明")
core_conclusion = Column(String(128), nullable=True, comment="核心结论")
bull_count = Column(Integer, nullable=True, comment="多头品种数")
bear_count = Column(Integer, nullable=True, comment="空头品种数")
neutral_count = Column(Integer, nullable=True, comment="震荡品种数")
opportunity_count = Column(Integer, nullable=True, comment="交易机会数")
risk_warnings = Column(JSON, nullable=True, comment="风险提示列表")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<ReviewPlanV2 {self.review_date}>"

@ -0,0 +1,615 @@
"""
复盘计划接口 - 提供复盘与交易计划相关数据 (V1 + V2)
"""
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.analysis_db import get_analysis_db
from app.analysis_models import (
ReviewDate, SymbolRanking, TradingPlan,
ReviewPlanV2, SymbolScoreV2, TradingPlanV2, SectorHeat,
)
from app.services.plan_generator import generate_plan, save_plan_to_db, get_plan_data
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/review", tags=["复盘计划"])
# ==================== 请求体模型 ====================
class GeneratePlanRequest(BaseModel):
review_date: str
# ==================== V1 复盘日期管理 ====================
@router.get("/dates")
def get_review_dates(limit: int = 10, db: Session = Depends(get_analysis_db)):
"""获取复盘日期列表"""
dates = db.query(ReviewDate).order_by(
ReviewDate.review_date.desc()
).limit(limit).all()
return {
"success": True,
"data": [{
"id": d.id,
"review_date": d.review_date,
"week_day": d.week_day
} for d in dates]
}
@router.post("/dates")
def create_review_date(
review_date: str,
week_day: Optional[str] = None,
db: Session = Depends(get_analysis_db)
):
"""创建复盘日期"""
existing = db.query(ReviewDate).filter(
ReviewDate.review_date == review_date
).first()
if existing:
return {
"success": False,
"message": f"日期 {review_date} 已存在"
}
if not week_day:
try:
dt = datetime.strptime(review_date, "%Y-%m-%d")
week_days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
week_day = week_days[dt.weekday()]
except:
week_day = ""
new_date = ReviewDate(
review_date=review_date,
week_day=week_day
)
db.add(new_date)
db.commit()
db.refresh(new_date)
return {
"success": True,
"data": {
"id": new_date.id,
"review_date": new_date.review_date,
"week_day": new_date.week_day
}
}
# ==================== V1 品种排名 ====================
@router.get("/rankings/{review_date_id}")
def get_rankings(review_date_id: int, db: Session = Depends(get_analysis_db)):
"""获取指定日期的品种排名"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
rankings = db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).order_by(
SymbolRanking.rank_type,
SymbolRanking.rank
).all()
rank_types = ["volume", "amplitude", "change", "open_interest"]
rank_type_names = {
"volume": "成交量排名",
"amplitude": "振幅排名",
"change": "涨跌幅排名",
"open_interest": "持仓量排名"
}
result = {}
for rt in rank_types:
result[rt] = {
"title": rank_type_names.get(rt, rt),
"items": []
}
for r in rankings:
if r.rank_type in result:
result[r.rank_type]["items"].append({
"id": r.id,
"symbol": r.symbol,
"name": r.name or r.symbol,
"rank": r.rank,
"value": r.value,
"price": r.price,
"change_pct": r.change_pct
})
return {
"success": True,
"data": {
"review_date": review_date.review_date,
"week_day": review_date.week_day,
"rankings": result
}
}
@router.post("/rankings")
def create_or_update_rankings(
review_date_id: int,
rankings: List[dict],
db: Session = Depends(get_analysis_db)
):
"""创建或更新品种排名"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).delete()
created = []
for r in rankings:
new_ranking = SymbolRanking(
review_date_id=review_date_id,
symbol=r.get("symbol"),
name=r.get("name"),
rank_type=r.get("rank_type"),
rank=r.get("rank", 1),
value=r.get("value"),
price=r.get("price"),
change_pct=r.get("change_pct")
)
db.add(new_ranking)
created.append({
"id": new_ranking.id,
"symbol": new_ranking.symbol,
"rank_type": new_ranking.rank_type,
"rank": new_ranking.rank
})
db.commit()
return {
"success": True,
"data": created
}
# ==================== V1 交易计划 ====================
@router.get("/plans/{review_date_id}")
def get_trading_plans(review_date_id: int, db: Session = Depends(get_analysis_db)):
"""获取指定日期的交易计划"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
plans = db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).order_by(
TradingPlan.score.desc()
).all()
return {
"success": True,
"data": {
"review_date": review_date.review_date,
"week_day": review_date.week_day,
"plans": [{
"id": p.id,
"symbol": p.symbol,
"name": p.name or p.symbol,
"plan_type": p.plan_type,
"score": p.score,
"logic": p.logic,
"reason": p.reason,
"entry_price": p.entry_price,
"stop_loss": p.stop_loss,
"take_profit": p.take_profit,
"confidence": p.confidence,
"position_suggestion": p.position_suggestion,
"created_at": p.created_at.isoformat() if p.created_at else None
} for p in plans]
}
}
@router.post("/plans")
def create_or_update_plans(
review_date_id: int,
plans: List[dict],
db: Session = Depends(get_analysis_db)
):
"""创建或更新交易计划"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).delete()
created = []
for p in plans:
new_plan = TradingPlan(
review_date_id=review_date_id,
symbol=p.get("symbol"),
name=p.get("name"),
plan_type=p.get("plan_type"),
score=p.get("score", 0),
logic=p.get("logic"),
reason=p.get("reason"),
entry_price=p.get("entry_price"),
stop_loss=p.get("stop_loss"),
take_profit=p.get("take_profit"),
confidence=p.get("confidence"),
position_suggestion=p.get("position_suggestion")
)
db.add(new_plan)
created.append({
"id": new_plan.id,
"symbol": new_plan.symbol,
"plan_type": new_plan.plan_type,
"score": new_plan.score
})
db.commit()
return {
"success": True,
"data": created
}
@router.post("/plans/batch")
def batch_create_review_data(
review_data: dict,
db: Session = Depends(get_analysis_db)
):
"""批量创建复盘数据(日期+排名+计划)"""
review_date_str = review_data.get("review_date")
week_day = review_data.get("week_day")
existing = db.query(ReviewDate).filter(
ReviewDate.review_date == review_date_str
).first()
if existing:
review_date_id = existing.id
else:
if not week_day:
try:
dt = datetime.strptime(review_date_str, "%Y-%m-%d")
week_days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
week_day = week_days[dt.weekday()]
except:
week_day = ""
new_date = ReviewDate(
review_date=review_date_str,
week_day=week_day
)
db.add(new_date)
db.commit()
db.refresh(new_date)
review_date_id = new_date.id
rankings = review_data.get("rankings", [])
if rankings:
db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).delete()
for r in rankings:
new_ranking = SymbolRanking(
review_date_id=review_date_id,
symbol=r.get("symbol"),
name=r.get("name"),
rank_type=r.get("rank_type"),
rank=r.get("rank", 1),
value=r.get("value"),
price=r.get("price"),
change_pct=r.get("change_pct")
)
db.add(new_ranking)
plans = review_data.get("plans", [])
if plans:
db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).delete()
for p in plans:
new_plan = TradingPlan(
review_date_id=review_date_id,
symbol=p.get("symbol"),
name=p.get("name"),
plan_type=p.get("plan_type"),
score=p.get("score", 0),
logic=p.get("logic"),
reason=p.get("reason"),
entry_price=p.get("entry_price"),
stop_loss=p.get("stop_loss"),
take_profit=p.get("take_profit"),
confidence=p.get("confidence"),
position_suggestion=p.get("position_suggestion")
)
db.add(new_plan)
db.commit()
return {
"success": True,
"data": {
"review_date_id": review_date_id,
"review_date": review_date_str,
"rankings_count": len(rankings),
"plans_count": len(plans)
}
}
@router.get("/summary/{review_date_id}")
def get_review_summary(review_date_id: int, db: Session = Depends(get_analysis_db)):
"""获取复盘摘要(包含排名和计划的完整数据)"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
rankings = db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).order_by(
SymbolRanking.rank_type,
SymbolRanking.rank
).all()
plans = db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).order_by(
TradingPlan.score.desc()
).all()
rank_types = ["volume", "amplitude", "change", "open_interest"]
rank_type_names = {
"volume": "成交量排名 Top 5",
"amplitude": "振幅排名 Top 5",
"change": "涨跌幅排名 Top 5",
"open_interest": "持仓量排名 Top 5"
}
rankings_result = {}
for rt in rank_types:
rankings_result[rt] = {
"title": rank_type_names.get(rt, rt),
"items": []
}
for r in rankings:
if r.rank_type in rankings_result:
rankings_result[r.rank_type]["items"].append({
"symbol": r.symbol,
"name": r.name or r.symbol,
"rank": r.rank,
"value": r.value,
"price": r.price,
"change_pct": r.change_pct
})
plans_result = [{
"id": p.id,
"symbol": p.symbol,
"name": p.name or p.symbol,
"plan_type": p.plan_type,
"score": p.score,
"logic": p.logic,
"reason": p.reason,
"entry_price": p.entry_price,
"stop_loss": p.stop_loss,
"take_profit": p.take_profit,
"confidence": p.confidence,
"position_suggestion": p.position_suggestion,
"created_at": p.created_at.isoformat() if p.created_at else None
} for p in plans]
return {
"success": True,
"data": {
"review_date": review_date.review_date,
"week_day": review_date.week_day,
"rankings": rankings_result,
"plans": plans_result
}
}
@router.delete("/clear")
def clear_review_data(db: Session = Depends(get_analysis_db)):
"""清除所有V1复盘与交易计划数据"""
try:
db.query(TradingPlan).delete()
db.query(SymbolRanking).delete()
db.query(ReviewDate).delete()
db.commit()
return {
"success": True,
"message": "复盘数据已清除"
}
except Exception as e:
db.rollback()
return {
"success": False,
"message": f"清除失败: {str(e)}"
}
# ==================== V2 生成交易计划 ====================
@router.post("/generate")
def generate_review_plan(
req: GeneratePlanRequest,
buffer_db: Session = Depends(get_db),
analysis_db: Session = Depends(get_analysis_db)
):
"""生成V2交易计划"""
try:
review_date_str = req.review_date
dt = datetime.strptime(review_date_str, "%Y-%m-%d")
week_days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
week_day = week_days[dt.weekday()]
# buffer_db 用于获取K线数据analysis_db 用于保存计划
plan_data = generate_plan(buffer_db, review_date_str, week_day)
plan_id = save_plan_to_db(analysis_db, plan_data)
return {
"success": True,
"data": {
"plan_id": plan_id,
"summary": {
"review_date": plan_data["review_date"],
"week_day": plan_data["week_day"],
"core_conclusion": plan_data.get("core_conclusion"),
"bull_count": plan_data.get("bull_count"),
"bear_count": plan_data.get("bear_count"),
"neutral_count": plan_data.get("neutral_count"),
"opportunity_count": plan_data.get("opportunity_count"),
"symbol_count": plan_data.get("symbol_count"),
}
}
}
except Exception as e:
logger.exception("生成交易计划失败")
return {
"success": False,
"message": f"生成失败: {str(e)}"
}
# ==================== V2 查询接口 ====================
@router.get("/v2/plans")
def list_v2_plans(
limit: int = 20,
db: Session = Depends(get_analysis_db)
):
"""获取V2复盘计划列表"""
try:
plans = db.query(ReviewPlanV2).order_by(
ReviewPlanV2.review_date.desc()
).limit(limit).all()
return {
"success": True,
"data": [{
"id": p.id,
"review_date": p.review_date,
"week_day": p.week_day,
"core_conclusion": p.core_conclusion,
"opportunity_count": p.opportunity_count,
"created_at": p.created_at.isoformat() if p.created_at else None,
} for p in plans]
}
except Exception as e:
logger.exception("获取V2计划列表失败")
return {
"success": False,
"message": f"查询失败: {str(e)}"
}
@router.get("/v2/plan/{plan_id}")
def get_v2_plan(plan_id: int, db: Session = Depends(get_analysis_db)):
"""根据ID获取完整V2计划数据"""
try:
result = get_plan_data(db, plan_id)
if not result:
return {
"success": False,
"message": f"计划 {plan_id} 不存在"
}
return {
"success": True,
"data": result
}
except Exception as e:
logger.exception(f"获取V2计划 {plan_id} 失败")
return {
"success": False,
"message": f"查询失败: {str(e)}"
}
@router.get("/v2/plan/by-date/{date}")
def get_v2_plan_by_date(date: str, db: Session = Depends(get_analysis_db)):
"""根据日期获取V2计划数据"""
try:
plan_meta = db.query(ReviewPlanV2).filter(
ReviewPlanV2.review_date == date
).first()
if not plan_meta:
return {
"success": False,
"message": f"日期 {date} 的计划不存在"
}
result = get_plan_data(db, plan_meta.id)
return {
"success": True,
"data": result
}
except Exception as e:
logger.exception(f"获取V2计划 by-date {date} 失败")
return {
"success": False,
"message": f"查询失败: {str(e)}"
}
@router.delete("/v2/clear")
def clear_v2_data(db: Session = Depends(get_analysis_db)):
"""清除所有V2数据"""
try:
db.query(SectorHeat).delete()
db.query(TradingPlanV2).delete()
db.query(SymbolScoreV2).delete()
db.query(ReviewPlanV2).delete()
db.commit()
return {
"success": True,
"message": "V2复盘数据已清除"
}
except Exception as e:
db.rollback()
return {
"success": False,
"message": f"清除失败: {str(e)}"
}

@ -135,12 +135,6 @@ def ai_config_page():
return FileResponse(str(STATIC_DIR / "ai_config.html"))
@app.get("/review-plan")
def review_plan_page():
"""复盘计划页面"""
return FileResponse(str(STATIC_DIR / "review_plan.html"))
@app.get("/api/v1/health")
def health():
return {"status": "ok", "service": "market-data-buffer"}

@ -0,0 +1,724 @@
"""
V2 交易计划生成器 - 5维度综合评分 + 多周期共振分析
"""
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from app.services.cache import get_cached_data
from app.analysis_models import (
ReviewPlanV2, SymbolScoreV2, TradingPlanV2, SectorHeat,
)
logger = logging.getLogger(__name__)
# 品种配置加载
CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config"
# 板块分类
SECTOR_MAP = {
"贵金属": ["沪银", "沪金"],
"有色金属": ["沪铜", "沪锌", "沪锡", "沪铅", "沪铝", "铝合金", "沪镍"],
"黑色系": ["焦炭", "焦煤", "螺纹钢", "热卷", "铁矿石"],
"能源": ["原油", "低硫燃油", "燃油"],
"化工": ["PTA", "甲醇", "尿素", "PVC", "乙二醇", "合成橡胶", "烧碱", "纯碱", "玻璃", "橡胶"],
"农产品": ["白糖", "棉花", "棕榈油", "豆粕"],
"新能源": ["氧化铝", "多晶硅", "碳酸锂", "工业硅"],
"股指": ["中证1000"],
"航运": ["集运欧线"],
"橡胶": ["20号胶"],
}
# 权重
WEIGHTS = {
"amplitude": 0.20,
"volume": 0.15,
"change": 0.15,
"trend": 0.35,
"activity": 0.15,
}
def load_symbols_config() -> Dict[str, str]:
"""加载品种配置 {中文名: 合约代码}"""
config_path = CONFIG_DIR / "symbols_config.json"
if not config_path.exists():
logger.warning(f"品种配置文件不存在: {config_path}")
return {}
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("futures", {})
def _get_candle_values(candles: List[dict]) -> Tuple[list, list, list, list, list]:
"""从K线列表提取 OHLCV"""
opens, highs, lows, closes, volumes = [], [], [], [], []
for c in candles:
opens.append(float(c.get("open", 0)))
highs.append(float(c.get("high", 0)))
lows.append(float(c.get("low", 0)))
closes.append(float(c.get("close", 0)))
volumes.append(float(c.get("volume", 0)))
return opens, highs, lows, closes, volumes
def _calc_single_period_trend(candles: List[dict]) -> float:
"""
计算单周期趋势分 (-50 ~ +50)
MA排列: 多头+50 / 空头-50
MACD: DIF>DEA +30 / DIF<DEA -30
综合: -50 ~ +50 (归一化后映射)
"""
if len(candles) < 5:
return 0.0
last = candles[-1]
ma10 = last.get("ma10")
ma20 = last.get("ma20")
macd_dif = last.get("macd_dif", 0) or 0
macd_dea = last.get("macd_dea", 0) or 0
score = 0.0
# MA排列
if ma10 and ma20:
if ma10 > ma20:
score += 50
else:
score -= 50
# MACD
if macd_dif > macd_dea:
score += 30
else:
score -= 30
# 归一化到 -50 ~ +50
return max(-50, min(50, score * 50 / 80))
def _calc_trend_score(t60: float, t15: float, t5: float) -> Tuple[float, str, str]:
"""
三周期共振判定
Returns: (趋势总分 0-100, 方向标签, 方向简写)
"""
# 共振判定
if t60 > 20 and t15 > 20 and t5 > 20:
direction = "多头共振"
tag = "强多"
raw = 80 + min(20, (t60 + t15 + t5) / 3 * 20 / 50)
elif t60 < -20 and t15 < -20 and t5 < -20:
direction = "空头共振"
tag = "强空"
raw = 80 + min(20, abs(t60 + t15 + t5) / 3 * 20 / 50)
elif t60 > 0 and t15 > 0:
direction = "偏多震荡"
tag = "偏多"
raw = 50 + min(20, (t60 + t15) / 2 * 20 / 50)
elif t60 < 0 and t15 < 0:
direction = "偏空震荡"
tag = "偏空"
raw = 50 + min(20, abs(t60 + t15) / 2 * 20 / 50)
else:
direction = "多空交织"
tag = "震荡"
raw = max(0, 40 - abs(t60 + t15 + t5) / 3)
return round(raw, 1), direction, tag
def _calc_pivot_points(high: float, low: float, close: float) -> dict:
"""计算枢轴点和支撑/阻力位"""
pivot = (high + low + close) / 3
r1 = 2 * pivot - low
s1 = 2 * pivot - high
r2 = pivot + (high - low)
s2 = pivot - (high - low)
return {"pivot": pivot, "r1": r1, "r2": r2, "s1": s1, "s2": s2}
def _normalize_scores(values: List[float], reverse: bool = False) -> List[float]:
"""将一组值归一化到 0-100"""
if not values:
return []
min_v = min(values)
max_v = max(values)
if max_v == min_v:
return [50.0] * len(values)
result = []
for v in values:
if reverse:
score = (max_v - v) / (max_v - min_v) * 100
else:
score = (v - min_v) / (max_v - min_v) * 100
result.append(round(score, 1))
return result
def _normalize_abs_scores(values: List[float]) -> List[float]:
"""将绝对值归一化到 0-100涨跌幅用绝对值排名"""
abs_values = [abs(v) for v in values]
return _normalize_scores(abs_values)
def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
"""
生成V2复盘与交易计划
Returns:
{
"review_date_id": int,
"summary": {...},
"scores": [...],
"plans": [...],
"sectors": [...],
}
"""
symbols_config = load_symbols_config()
if not symbols_config:
raise ValueError("品种配置文件为空")
# 反转映射: 合约代码 -> 中文名
code_to_name = {v: k for k, v in symbols_config.items()}
all_symbols = list(symbols_config.values())
logger.info(f"开始生成交易计划: {review_date_str} ({week_day}), 共 {len(all_symbols)} 个品种")
# 将复盘日期作为数据截止时间(当天 23:59:59
try:
end_time = datetime.strptime(review_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
except ValueError:
end_time = datetime.now()
# ==================== 第一步: 数据收集 ====================
symbol_data = {}
periods_needed = ["daily", "60min", "15min", "5min"]
for symbol in all_symbols:
try:
data = get_cached_data(
db, symbol, "futures",
periods=periods_needed,
end_time=end_time,
max_candles=100,
)
if data and data.get("timeframes"):
symbol_data[symbol] = data
else:
logger.warning(f"{symbol} 无数据")
except Exception as e:
logger.error(f"获取 {symbol} 数据失败: {e}")
if not symbol_data:
raise ValueError("无法从数据库获取任何品种数据,请先进行数据更新")
logger.info(f"成功获取 {len(symbol_data)}/{len(all_symbols)} 个品种数据")
# ==================== 第二步: 多维度打分 ====================
raw_scores = []
for symbol, data in symbol_data.items():
name = code_to_name.get(symbol, symbol)
tf = data.get("timeframes", {})
daily = tf.get("daily", [])
h1 = tf.get("60min", [])
m15 = tf.get("15min", [])
m5 = tf.get("5min", [])
# 日线数据
if not daily or len(daily) < 2:
continue
_, d_highs, d_lows, d_closes, d_volumes = _get_candle_values(daily)
today_high = d_highs[-1]
today_low = d_lows[-1]
today_close = d_closes[-1]
prev_close = d_closes[-2] if len(d_closes) >= 2 else today_close
today_volume = d_volumes[-1]
# 近5日均量
recent_vols = d_volumes[-6:-1] if len(d_volumes) >= 6 else d_volumes[:-1]
avg_vol_5 = sum(recent_vols) / len(recent_vols) if recent_vols else today_volume
# A. 振幅
amplitude_pct = (today_high - today_low) / today_close * 100 if today_close else 0
# B. 量比
volume_ratio = today_volume / avg_vol_5 if avg_vol_5 > 0 else 1.0
# C. 涨跌幅
change_pct = (today_close - prev_close) / prev_close * 100 if prev_close else 0
# D. 多周期趋势
t60 = _calc_single_period_trend(h1) if h1 else 0
t15 = _calc_single_period_trend(m15) if m15 else 0
t5 = _calc_single_period_trend(m5) if m5 else 0
trend_total, direction, direction_tag = _calc_trend_score(t60, t15, t5)
# E. 活跃度 (5min K线近10根)
if m5 and len(m5) >= 5:
recent_m5 = m5[-10:] if len(m5) >= 10 else m5
_, _, _, m5_closes, m5_vols = _get_candle_values(recent_m5)
# 价格变动率
if m5_closes and m5_closes[0] > 0:
price_change_rate = abs(m5_closes[-1] - m5_closes[0]) / m5_closes[0] * 100
else:
price_change_rate = 0
# 量能活跃度
avg_m5_vol = sum(m5_vols) / len(m5_vols) if m5_vols else 0
vol_activity = min(100, avg_m5_vol / 100) if avg_m5_vol > 0 else 0
price_activity = min(100, price_change_rate * 50)
activity_score = (vol_activity + price_activity) / 2
else:
activity_score = 0
# 关键点位
pivots = _calc_pivot_points(today_high, today_low, today_close)
raw_scores.append({
"symbol": symbol,
"name": name,
"close_price": today_close,
"prev_close": prev_close,
"high_price": today_high,
"low_price": today_low,
"volume": today_volume,
"avg_volume_5": avg_vol_5,
"amplitude_pct": round(amplitude_pct, 2),
"volume_ratio": round(volume_ratio, 2),
"change_pct": round(change_pct, 2),
"amplitude_raw": amplitude_pct,
"volume_raw": volume_ratio,
"change_raw": abs(change_pct),
"trend_score": trend_total,
"trend_60m": round(t60, 1),
"trend_15m": round(t15, 1),
"trend_5m": round(t5, 1),
"direction": direction,
"direction_tag": direction_tag,
"activity_raw": activity_score,
"pivots": pivots,
})
if not raw_scores:
raise ValueError("没有足够的数据进行评分计算")
# ==================== 第三步: 归一化评分 ====================
amp_scores = _normalize_scores([s["amplitude_raw"] for s in raw_scores])
vol_scores = _normalize_scores([s["volume_raw"] for s in raw_scores])
chg_scores = _normalize_abs_scores([s["change_raw"] for s in raw_scores])
act_scores = _normalize_scores([s["activity_raw"] for s in raw_scores])
for i, s in enumerate(raw_scores):
s["amplitude_score"] = amp_scores[i]
s["volume_score"] = vol_scores[i]
s["change_score"] = chg_scores[i]
s["activity_score"] = act_scores[i]
# 综合评分 = 振幅×0.20 + 量能×0.15 + 涨跌幅×0.15 + 趋势×0.35 + 活跃度×0.15
# 趋势分映射: trend_score 0-100 直接使用
s["composite_score"] = round(
amp_scores[i] * WEIGHTS["amplitude"]
+ vol_scores[i] * WEIGHTS["volume"]
+ chg_scores[i] * WEIGHTS["change"]
+ s["trend_score"] * WEIGHTS["trend"]
+ act_scores[i] * WEIGHTS["activity"],
1
)
# 排序
raw_scores.sort(key=lambda x: x["composite_score"], reverse=True)
# ==================== 第四步: 分类 ====================
for rank, s in enumerate(raw_scores, 1):
s["rank"] = rank
score = s["composite_score"]
trend = s["trend_score"]
if score >= 55 and trend >= 50:
s["category"] = "green"
elif score >= 45 or (score >= 40 and 30 <= trend < 50):
s["category"] = "yellow"
else:
s["category"] = "red"
# ==================== 第五步: 生成交易计划 ====================
plans = []
green_items = [s for s in raw_scores if s["category"] == "green"]
for s in green_items:
direction = "long" if s["trend_score"] >= 50 else "short"
pivots = s["pivots"]
if direction == "long":
entry_low = round(pivots["s1"], 2)
entry_high = round(pivots["pivot"], 2)
stop_loss = round(pivots["s2"], 2)
target1 = round(pivots["r1"], 2)
target2 = round(pivots["r2"], 2)
else:
entry_low = round(pivots["pivot"], 2)
entry_high = round(pivots["r1"], 2)
stop_loss = round(pivots["r2"], 2)
target1 = round(pivots["s1"], 2)
target2 = round(pivots["s2"], 2)
plans.append({
"symbol": s["symbol"],
"name": s["name"],
"direction": direction,
"composite_score": s["composite_score"],
"entry_low": entry_low,
"entry_high": entry_high,
"stop_loss": stop_loss,
"target1": target1,
"target2": target2,
"trigger": "回踩支撑企稳 + 5m放量突破",
"amplitude_score": s["amplitude_score"],
"volume_score": s["volume_score"],
"trend_score": s["trend_score"],
"activity_score": s["activity_score"],
"category": s["category"],
})
# ==================== 第六步: 板块热度 ====================
sectors = _calc_sector_heat(raw_scores, code_to_name)
# ==================== 第七步: 汇总 ====================
bull_count = sum(1 for s in raw_scores if s["trend_score"] >= 50)
bear_count = sum(1 for s in raw_scores if s["trend_score"] < 30)
neutral_count = len(raw_scores) - bull_count - bear_count
# 核心结论
top3 = raw_scores[:3]
top_names = [f"{s['symbol']} {s['name']}" for s in top3]
main_direction = "多头" if bull_count > bear_count else "空头" if bear_count > bull_count else "震荡"
core_conclusion = f"{main_direction}格局{'延续' if bull_count > 15 else '分化'}{'贵金属+黑色系共振做多' if bull_count > 20 else '板块轮动明显'}"
# 风险提示
risk_warnings = []
if top3:
top_sym = top3[0]
risk_warnings.append(
f"极值品种严禁追{'' if top_sym['trend_score'] >= 50 else ''}"
f"{top_sym['symbol']}({top_sym['composite_score']}分) "
f"{'周一大概率高开' if top_sym['change_pct'] > 2 else '注意风险控制'}"
)
if bear_count > 0:
bear_syms = [s for s in raw_scores if s["category"] == "red" and s["trend_score"] < 30][:2]
if bear_syms:
names = "/".join(s["symbol"] for s in bear_syms)
risk_warnings.append(f"空头品种谨慎:{names} 趋势偏空,需严格止损")
risk_warnings.append("关注宏观数据和消息面变化,可能影响开盘方向")
if any(s["volume_ratio"] > 2.5 for s in raw_scores):
high_vol = [s for s in raw_scores if s["volume_ratio"] > 2.5]
risk_warnings.append(
f"量能异常品种:{', '.join(s['symbol'] for s in high_vol[:3])} "
f"量比超2.5倍,需确认量能可持续性"
)
return {
"review_date": review_date_str,
"week_day": week_day,
"data_basis": f"{review_date_str} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析",
"core_conclusion": core_conclusion,
"bull_count": bull_count,
"bear_count": bear_count,
"neutral_count": neutral_count,
"opportunity_count": len(green_items),
"risk_warnings": risk_warnings,
"scores": raw_scores,
"plans": plans,
"sectors": sectors,
"symbol_count": len(symbol_data),
}
def _calc_sector_heat(scores: List[dict], code_to_name: dict) -> List[dict]:
"""计算板块热度"""
# 构建 symbol -> score 映射
name_to_score = {}
for s in scores:
name_to_score[s["name"]] = s
sectors = []
for sector_name, members_names in SECTOR_MAP.items():
members = []
for mn in members_names:
if mn in name_to_score:
sc = name_to_score[mn]
members.append({
"symbol": sc["symbol"],
"name": sc["name"],
"score": sc["composite_score"],
"trend": sc["trend_score"],
"change_pct": sc["change_pct"],
})
if not members:
continue
avg_score = round(sum(m["score"] for m in members) / len(members), 1)
avg_trend = round(sum(m["trend"] for m in members) / len(members), 0)
if avg_trend > 20:
direction = "多头"
elif avg_trend < -20:
direction = "空头"
else:
direction = "震荡"
# 热度等级
if avg_score >= 75:
heat = 3
elif avg_score >= 60:
heat = 2
elif avg_score >= 45:
heat = 1
else:
heat = 0
# 龙头
leader = max(members, key=lambda m: m["score"])
sectors.append({
"sector_name": sector_name,
"avg_score": avg_score,
"avg_trend": int(avg_trend),
"direction": direction,
"heat_level": heat,
"leader_symbol": leader["symbol"],
"leader_score": leader["score"],
"members": sorted(members, key=lambda m: m["score"], reverse=True),
})
sectors.sort(key=lambda s: s["avg_score"], reverse=True)
return sectors
def save_plan_to_db(db: Session, plan_data: dict) -> int:
"""将计划数据保存到数据库"""
review_date_str = plan_data["review_date"]
week_day = plan_data["week_day"]
# 清理旧数据
existing = db.query(ReviewPlanV2).filter_by(review_date=review_date_str).first()
if existing:
review_plan_id = existing.id
# 清理关联数据
db.query(SymbolScoreV2).filter_by(review_date_id=review_plan_id).delete()
db.query(TradingPlanV2).filter_by(review_date_id=review_plan_id).delete()
db.query(SectorHeat).filter_by(review_date_id=review_plan_id).delete()
# 更新元数据
existing.week_day = week_day
existing.data_basis = plan_data.get("data_basis")
existing.core_conclusion = plan_data.get("core_conclusion")
existing.bull_count = plan_data.get("bull_count")
existing.bear_count = plan_data.get("bear_count")
existing.neutral_count = plan_data.get("neutral_count")
existing.opportunity_count = plan_data.get("opportunity_count")
existing.risk_warnings = plan_data.get("risk_warnings")
else:
review_plan = ReviewPlanV2(
review_date=review_date_str,
week_day=week_day,
data_basis=plan_data.get("data_basis"),
core_conclusion=plan_data.get("core_conclusion"),
bull_count=plan_data.get("bull_count"),
bear_count=plan_data.get("bear_count"),
neutral_count=plan_data.get("neutral_count"),
opportunity_count=plan_data.get("opportunity_count"),
risk_warnings=plan_data.get("risk_warnings"),
)
db.add(review_plan)
db.flush()
review_plan_id = review_plan.id
# 保存评分数据
for s in plan_data.get("scores", []):
pivots = s.get("pivots", {})
score_record = SymbolScoreV2(
review_date_id=review_plan_id,
symbol=s["symbol"],
name=s["name"],
close_price=s.get("close_price"),
prev_close=s.get("prev_close"),
high_price=s.get("high_price"),
low_price=s.get("low_price"),
volume=s.get("volume"),
avg_volume_5=s.get("avg_volume_5"),
amplitude_score=s.get("amplitude_score"),
volume_score=s.get("volume_score"),
change_score=s.get("change_score"),
trend_score=s.get("trend_score"),
activity_score=s.get("activity_score"),
composite_score=s.get("composite_score"),
amplitude_pct=s.get("amplitude_pct"),
change_pct=s.get("change_pct"),
volume_ratio=s.get("volume_ratio"),
trend_60m=s.get("trend_60m"),
trend_15m=s.get("trend_15m"),
trend_5m=s.get("trend_5m"),
direction=s.get("direction"),
direction_tag=s.get("direction_tag"),
category=s.get("category"),
pivot=pivots.get("pivot"),
r1=pivots.get("r1"),
r2=pivots.get("r2"),
s1=pivots.get("s1"),
s2=pivots.get("s2"),
rank=s.get("rank"),
)
db.add(score_record)
# 保存交易计划
for p in plan_data.get("plans", []):
plan_record = TradingPlanV2(
review_date_id=review_plan_id,
symbol=p["symbol"],
name=p["name"],
direction=p["direction"],
composite_score=p.get("composite_score"),
entry_low=p.get("entry_low"),
entry_high=p.get("entry_high"),
stop_loss=p.get("stop_loss"),
target1=p.get("target1"),
target2=p.get("target2"),
trigger=p.get("trigger"),
amplitude_score=p.get("amplitude_score"),
volume_score=p.get("volume_score"),
trend_score=p.get("trend_score"),
activity_score=p.get("activity_score"),
category=p.get("category"),
)
db.add(plan_record)
# 保存板块热度
for sec in plan_data.get("sectors", []):
sector_record = SectorHeat(
review_date_id=review_plan_id,
sector_name=sec["sector_name"],
avg_score=sec.get("avg_score"),
avg_trend=sec.get("avg_trend"),
direction=sec.get("direction"),
heat_level=sec.get("heat_level"),
leader_symbol=sec.get("leader_symbol"),
leader_score=sec.get("leader_score"),
members=sec.get("members"),
)
db.add(sector_record)
db.commit()
logger.info(f"交易计划已保存: {review_date_str}, ID={review_plan_id}")
return review_plan_id
def get_plan_data(db: Session, review_plan_id: int) -> Optional[dict]:
"""从数据库读取完整的计划数据"""
plan_meta = db.query(ReviewPlanV2).filter_by(id=review_plan_id).first()
if not plan_meta:
return None
scores = db.query(SymbolScoreV2).filter_by(
review_date_id=review_plan_id
).order_by(SymbolScoreV2.composite_score.desc()).all()
plans = db.query(TradingPlanV2).filter_by(
review_date_id=review_plan_id
).order_by(TradingPlanV2.composite_score.desc()).all()
sectors = db.query(SectorHeat).filter_by(
review_date_id=review_plan_id
).order_by(SectorHeat.avg_score.desc()).all()
return {
"meta": {
"id": plan_meta.id,
"review_date": plan_meta.review_date,
"week_day": plan_meta.week_day,
"data_basis": plan_meta.data_basis,
"core_conclusion": plan_meta.core_conclusion,
"bull_count": plan_meta.bull_count,
"bear_count": plan_meta.bear_count,
"neutral_count": plan_meta.neutral_count,
"opportunity_count": plan_meta.opportunity_count,
"risk_warnings": plan_meta.risk_warnings,
"created_at": plan_meta.created_at.isoformat() if plan_meta.created_at else None,
},
"scores": [_score_to_dict(s) for s in scores],
"plans": [_plan_to_dict(p) for p in plans],
"sectors": [_sector_to_dict(s) for s in sectors],
}
def _score_to_dict(s: SymbolScoreV2) -> dict:
return {
"id": s.id,
"symbol": s.symbol,
"name": s.name,
"close_price": s.close_price,
"prev_close": s.prev_close,
"high_price": s.high_price,
"low_price": s.low_price,
"volume": s.volume,
"avg_volume_5": s.avg_volume_5,
"amplitude_score": s.amplitude_score,
"volume_score": s.volume_score,
"change_score": s.change_score,
"trend_score": s.trend_score,
"activity_score": s.activity_score,
"composite_score": s.composite_score,
"amplitude_pct": s.amplitude_pct,
"change_pct": s.change_pct,
"volume_ratio": s.volume_ratio,
"trend_60m": s.trend_60m,
"trend_15m": s.trend_15m,
"trend_5m": s.trend_5m,
"direction": s.direction,
"direction_tag": s.direction_tag,
"category": s.category,
"pivot": s.pivot,
"r1": s.r1,
"r2": s.r2,
"s1": s.s1,
"s2": s.s2,
"rank": s.rank,
}
def _plan_to_dict(p: TradingPlanV2) -> dict:
return {
"id": p.id,
"symbol": p.symbol,
"name": p.name,
"direction": p.direction,
"composite_score": p.composite_score,
"entry_low": p.entry_low,
"entry_high": p.entry_high,
"stop_loss": p.stop_loss,
"target1": p.target1,
"target2": p.target2,
"trigger": p.trigger,
"amplitude_score": p.amplitude_score,
"volume_score": p.volume_score,
"trend_score": p.trend_score,
"activity_score": p.activity_score,
"category": p.category,
}
def _sector_to_dict(s: SectorHeat) -> dict:
return {
"id": s.id,
"sector_name": s.sector_name,
"avg_score": s.avg_score,
"avg_trend": s.avg_trend,
"direction": s.direction,
"heat_level": s.heat_level,
"leader_symbol": s.leader_symbol,
"leader_score": s.leader_score,
"members": s.members,
}

@ -336,57 +336,125 @@
.container { padding: 16px; }
}
/* 复盘计划样式 */
.review-toolbar { display: flex; gap: 16px; margin-bottom: 24px; align-items: center; justify-content: center; }
.date-selector { background: #fff; border: 1px solid rgba(0,0,0,0.05); border-radius: 12px; padding: 0 16px; height: 44px; font-size: 14px; color: var(--text-primary); box-shadow: var(--shadow-sm); cursor: pointer; min-width: 180px; font-family: inherit; outline: none; }
.date-selector:hover, .date-selector:focus { border-color: var(--color-brand); }
.summary-header { background: #fff; border-radius: 20px; padding: 20px 24px; box-shadow: var(--shadow-sm); margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center; }
.summary-header h2 { font-size: 20px; font-weight: 700; }
.current-date { font-size: 14px; color: var(--text-secondary); background: #F5F5F7; padding: 6px 14px; border-radius: 8px; font-weight: 500; }
.summary-layout { display: grid; grid-template-columns: 1fr 360px; gap: 20px; }
.summary-left { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.rank-section { background: #fff; border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); }
.rank-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(0,0,0,0.05); }
.rank-list { display: flex; flex-direction: column; gap: 6px; }
.rank-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; border-bottom: 1px dashed rgba(0,0,0,0.04); cursor: pointer; }
.rank-row:last-child { border-bottom: none; }
.rank-rank { width: 20px; font-size: 12px; font-weight: 700; color: var(--text-tertiary); text-align: center; }
.rank-rank.top { color: var(--color-brand); }
.rank-name { flex: 1; margin-left: 8px; font-size: 13px; font-weight: 500; }
.rank-val { font-size: 13px; font-weight: 600; }
.rank-val.up { color: var(--color-up); }
.rank-val.down { color: var(--color-down); }
.summary-right { display: flex; flex-direction: column; gap: 16px; }
.plan-header { font-size: 15px; font-weight: 700; color: var(--color-ai); }
.plan-list { display: flex; flex-direction: column; gap: 6px; }
.plan-list-item { background: #fff; border-radius: 12px; padding: 12px 16px; border: 1px solid rgba(0,0,0,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.plan-list-item:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.plan-list-item.expanded { border-color: var(--color-ai); box-shadow: var(--shadow-md); }
.plan-list-left { display: flex; align-items: center; gap: 10px; }
.plan-list-code { font-size: 14px; font-weight: 700; }
.plan-badge { font-size: 10px; padding: 4px 12px; border-radius: 9999px; font-weight: 700; }
.plan-badge.long { background: rgba(52,199,89,0.15); color: var(--color-down); }
.plan-badge.short { background: rgba(255,59,48,0.15); color: var(--color-up); }
.plan-list-score { font-size: 12px; font-weight: 600; color: var(--color-ai); background: #F8F5FF; padding: 2px 8px; border-radius: 6px; }
.plan-list-arrow { font-size: 12px; color: var(--text-tertiary); transition: transform .2s; }
.plan-list-item.expanded .plan-list-arrow { transform: rotate(180deg); }
.plan-detail { max-height: 0; overflow: hidden; transition: max-height .35s ease; }
.plan-detail.open { max-height: 600px; }
.plan-card { background: #fff; border-radius: 16px; padding: 20px; box-shadow: var(--shadow-md); margin-bottom: 16px; border: 1px solid rgba(0,0,0,0.05); position: relative; }
.plan-card::before { content: "AI"; position: absolute; top: -9px; left: 16px; background: var(--color-ai); color: #fff; font-size: 10px; padding: 2px 8px; border-radius: 8px; font-weight: 600; }
.plan-ai { background: #F8F5FF; border-radius: 12px; padding: 12px; margin-bottom: 12px; font-size: 12px; line-height: 1.5; color: var(--text-secondary); }
.plan-ai strong { color: var(--color-ai); }
.plan-targets { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.plan-target { background: #F5F5F7; border-radius: 10px; padding: 10px 8px; text-align: center; }
.plan-target-label { font-size: 10px; color: var(--text-tertiary); margin-bottom: 4px; }
.plan-target-val { font-size: 14px; font-weight: 700; }
.plan-target-val.green { color: var(--color-down); }
.plan-target-val.red { color: var(--color-up); }
.plan-target-val.neutral { color: var(--text-primary); }
.plan-note { font-size: 10px; color: var(--text-tertiary); text-align: center; margin-top: 8px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; gap: 16px; }
.empty-icon { font-size: 64px; opacity: 0.3; }
.empty-text { font-size: 16px; color: var(--text-tertiary); }
/* 复盘计划样式 - 与品种分析页面一致的亮色主题 */
#review-view {
background: var(--bg-page);
border-radius: 20px;
padding: 24px 0;
min-height: 600px;
}
.rv-toolbar { display: flex; gap: 12px; margin-bottom: 24px; align-items: center; justify-content: center; flex-wrap: wrap; }
.rv-date-selector { background: #fff; border: 1px solid rgba(0,0,0,0.05); border-radius: 12px; padding: 0 16px; height: 44px; font-size: 14px; color: var(--text-primary); box-shadow: var(--shadow-sm); min-width: 180px; outline: none; font-family: inherit; }
.rv-date-selector:focus { border-color: var(--color-brand); box-shadow: 0 0 0 3px rgba(0,122,255,0.15); }
.rv-btn { padding: 10px 20px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; display: flex; align-items: center; gap: 8px; transition: all 0.2s; font-family: inherit; }
.rv-btn-primary { background: var(--color-brand); color: #fff; }
.rv-btn-primary:hover { background: #0066d6; box-shadow: 0 4px 10px rgba(0,122,255,0.3); }
.rv-btn-secondary { background: #fff; color: var(--text-secondary); box-shadow: var(--shadow-sm); }
.rv-btn-secondary:hover { background: #F5F5F7; }
.rv-hero { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20px; padding: 32px; margin-bottom: 24px; text-align: center; color: #fff; }
.rv-hero h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; color: #fff; }
.rv-hero h1 span { font-size: 18px; opacity: 0.8; }
.rv-hero-subtitle { font-size: 14px; color: rgba(255,255,255,0.7); margin-bottom: 12px; }
.rv-hero-conclusion { font-size: 16px; color: #fff; font-weight: 500; }
.rv-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.rv-stat-card { background: var(--bg-card); border-radius: 16px; padding: 20px; text-align: center; box-shadow: var(--shadow-sm); transition: transform 0.2s, box-shadow 0.2s; }
.rv-stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.rv-stat-label { font-size: 12px; color: var(--text-tertiary); margin-bottom: 8px; text-transform: uppercase; font-weight: 600; }
.rv-stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.rv-stat-text { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.rv-risk-banner { background: rgba(255,59,48,0.06); border: 1px solid rgba(255,59,48,0.15); border-radius: 16px; padding: 16px 20px; margin-bottom: 24px; }
.rv-risk-title { font-size: 14px; font-weight: 600; color: var(--color-up); margin-bottom: 8px; }
.rv-risk-list { list-style: none; padding: 0; }
.rv-risk-list li { font-size: 13px; color: var(--text-secondary); padding: 4px 0; }
.rv-risk-list li::before { content: "⚠️ "; }
.rv-nav { display: flex; gap: 8px; margin-bottom: 24px; overflow-x: auto; padding-bottom: 8px; position: sticky; top: 64px; background: var(--bg-page); z-index: 10; padding-top: 8px; }
.rv-nav-item { padding: 8px 16px; border-radius: 9999px; font-size: 13px; background: #fff; color: var(--text-secondary); cursor: pointer; white-space: nowrap; transition: all 0.2s; text-decoration: none; box-shadow: var(--shadow-sm); font-weight: 500; }
.rv-nav-item:hover { background: var(--color-brand); color: #fff; box-shadow: 0 4px 10px rgba(0,122,255,0.2); }
.rv-section { margin-bottom: 32px; }
.rv-section-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid rgba(0,0,0,0.05); color: var(--text-primary); }
.rv-green-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.rv-opp-card { background: var(--bg-card); border-radius: 20px; padding: 20px; box-shadow: var(--shadow-md); transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); position: relative; overflow: hidden; }
.rv-opp-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.rv-opp-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, var(--color-down), var(--color-brand)); }
.rv-opp-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rv-opp-symbol { font-size: 16px; font-weight: 700; color: var(--text-primary); }
.rv-opp-score { background: rgba(52,199,89,0.12); color: var(--color-down); padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 700; }
.rv-opp-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; font-size: 13px; }
.rv-opp-info-item { display: flex; justify-content: space-between; padding: 4px 0; }
.rv-opp-info-label { color: var(--text-tertiary); }
.rv-score-bars { margin-bottom: 12px; }
.rv-score-bar-item { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px; }
.rv-score-bar-label { width: 50px; color: var(--text-tertiary); }
.rv-score-bar-track { flex: 1; height: 6px; background: #F5F5F7; border-radius: 3px; overflow: hidden; }
.rv-score-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.rv-score-bar-value { width: 30px; text-align: right; font-weight: 600; }
.rv-plan-box { background: #F5F5F7; border-radius: 12px; padding: 12px; font-size: 12px; }
.rv-plan-row { display: flex; justify-content: space-between; margin-bottom: 4px; color: var(--text-secondary); }
.rv-plan-trigger { color: var(--color-ai); margin-top: 8px; font-style: italic; }
.rv-watch-list, .rv-avoid-list { display: flex; flex-wrap: wrap; gap: 12px; }
.rv-watch-item, .rv-avoid-item { background: var(--bg-card); border-radius: 12px; padding: 12px 16px; display: flex; align-items: center; gap: 12px; box-shadow: var(--shadow-sm); transition: all 0.2s; }
.rv-watch-item:hover, .rv-avoid-item:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.rv-watch-item { border-left: 3px solid var(--color-neutral); }
.rv-avoid-item { border-left: 3px solid var(--color-up); }
.rv-item-symbol { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.rv-item-score { font-size: 12px; color: var(--text-tertiary); }
.rv-item-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.rv-tag-bull { background: rgba(52,199,89,0.12); color: var(--color-down); }
.rv-tag-bear { background: rgba(255,59,48,0.12); color: var(--color-up); }
.rv-tag-neutral { background: #F5F5F7; color: var(--text-tertiary); }
.rv-ranking-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rv-ranking-table th { background: #F5F5F7; padding: 12px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; text-transform: uppercase; }
.rv-ranking-table td { padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); }
.rv-ranking-table tr:hover { background: rgba(0,122,255,0.03); }
.rv-table-score-bar { width: 80px; height: 6px; background: #F5F5F7; border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
.rv-table-score-fill { height: 100%; border-radius: 3px; }
.rv-detail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.rv-detail-card { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); transition: all 0.2s; }
.rv-detail-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.rv-detail-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; color: var(--color-brand); }
.rv-detail-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; }
.rv-detail-metric { display: flex; justify-content: space-between; padding: 6px 8px; background: #F5F5F7; border-radius: 8px; color: var(--text-secondary); }
.rv-pivot-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.rv-pivot-tag { font-size: 11px; padding: 4px 8px; border-radius: 6px; background: #F5F5F7; font-weight: 500; }
.rv-pivot-tag.resist { color: var(--color-up); background: rgba(255,59,48,0.08); }
.rv-pivot-tag.support { color: var(--color-down); background: rgba(52,199,89,0.08); }
.rv-pivot-tag.pivot { color: var(--color-ai); background: rgba(175,82,222,0.08); }
.rv-sector-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.rv-sector-card { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); transition: all 0.2s; }
.rv-sector-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.rv-sector-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rv-sector-name { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.rv-sector-heat { font-size: 18px; }
.rv-sector-info { font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; }
.rv-sector-members { display: flex; flex-wrap: wrap; gap: 6px; }
.rv-sector-member { font-size: 11px; padding: 4px 8px; background: #F5F5F7; border-radius: 6px; color: var(--text-secondary); }
.rv-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; gap: 16px; }
.rv-empty-icon { font-size: 64px; opacity: 0.3; }
.rv-empty-text { font-size: 16px; color: var(--text-tertiary); }
.rv-loading { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #fff; padding: 32px 48px; border-radius: 20px; z-index: 1000; text-align: center; box-shadow: var(--shadow-lg); }
.rv-loading.active { display: block; }
.rv-loading p { color: var(--text-secondary); font-size: 14px; }
.rv-spinner { width: 40px; height: 40px; border: 3px solid #F5F5F7; border-top-color: var(--color-brand); border-radius: 50%; animation: rv-spin 1s linear infinite; margin: 0 auto 16px; }
@keyframes rv-spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.rv-stats-grid { grid-template-columns: 1fr; }
.rv-green-grid { grid-template-columns: 1fr; }
.rv-detail-grid { grid-template-columns: 1fr; }
.rv-sector-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
@ -621,56 +689,101 @@
</div>
</div>
<!-- 复盘计划视图 -->
<!-- 复盘计划视图 - V2 -->
<div id="review-view" class="view">
<div class="review-toolbar">
<input type="date" id="review-date-selector" class="date-selector" />
<button id="btn-review-plan" class="btn-primary" style="padding: 10px 20px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; background: var(--color-brand); color: #fff; display: flex; align-items: center; gap: 8px;">
<!-- 工具栏 -->
<div class="rv-toolbar">
<input type="date" id="rv-date-selector" class="rv-date-selector" />
<button id="rv-btn-generate" class="rv-btn rv-btn-primary">
<span>📊</span>
<span>复盘与计划</span>
</button>
<button id="rv-btn-clear" class="rv-btn rv-btn-secondary">
<span>🗑️</span>
<span>清除数据</span>
</button>
</div>
<div id="review-summary-content" class="summary-content" style="display: none;">
<div class="summary-header">
<h2>复盘与交易计划</h2>
<div class="current-date" id="review-current-date"></div>
</div>
<div class="summary-layout">
<div class="summary-left">
<div class="rank-section">
<div class="rank-title">成交量排名 Top 5</div>
<div class="rank-list" id="review-rank-volume"></div>
</div>
<div class="rank-section">
<div class="rank-title">振幅排名 Top 5</div>
<div class="rank-list" id="review-rank-amplitude"></div>
</div>
<div class="rank-section">
<div class="rank-title">涨跌幅排名 Top 5</div>
<div class="rank-list" id="review-rank-change"></div>
</div>
<div class="rank-section">
<div class="rank-title">持仓量排名 Top 5</div>
<div class="rank-list" id="review-rank-oi"></div>
</div>
</div>
<div class="summary-right">
<div class="plan-header">交易机会</div>
<div class="plan-list" id="review-plan-list"></div>
<div class="plan-note">以上由 AI 基于多维因子自动生成,仅供参考,不构成投资建议。</div>
</div>
</div>
</div>
<div id="review-empty-state" class="empty-state">
<div class="empty-icon">📊</div>
<div class="empty-text">请选择日期或点击"复盘与计划"按钮</div>
<!-- 加载指示器 -->
<div id="rv-loading" class="rv-loading">
<div class="rv-spinner"></div>
<p>正在生成复盘计划,请稍候...</p>
</div>
<!-- 内容区域 -->
<div id="rv-content-area" style="display: none;">
<!-- Hero -->
<section id="rv-hero-section" class="rv-hero"></section>
<!-- 统计卡片 -->
<section id="rv-stats-grid" class="rv-stats-grid"></section>
<!-- 风险提示 -->
<section id="rv-risk-banner" class="rv-risk-banner" style="display: none;">
<div class="rv-risk-title">⚠️ 风险提示</div>
<ul id="rv-risk-list" class="rv-risk-list"></ul>
</section>
<!-- 导航 -->
<nav class="rv-nav">
<a href="#rv-hero-section" class="rv-nav-item">顶部</a>
<a href="#rv-green-section" class="rv-nav-item">交易机会</a>
<a href="#rv-yellow-section" class="rv-nav-item">重点关注</a>
<a href="#rv-red-section" class="rv-nav-item">规避</a>
<a href="#rv-table-section" class="rv-nav-item">全品种排名</a>
<a href="#rv-details-section" class="rv-nav-item">品种详情</a>
<a href="#rv-sectors-section" class="rv-nav-item">板块热度</a>
</nav>
<!-- 交易机会 -->
<section id="rv-green-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">🟢 交易机会品种</h2>
<div id="rv-green-grid" class="rv-green-grid"></div>
</section>
<!-- 重点关注 -->
<section id="rv-yellow-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">👀 重点关注品种</h2>
<div id="rv-yellow-list" class="rv-watch-list"></div>
</section>
<!-- 规避 -->
<section id="rv-red-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">🔴 规避品种</h2>
<div id="rv-red-list" class="rv-avoid-list"></div>
</section>
<!-- 全品种排名 -->
<section id="rv-table-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">📊 全品种综合排名</h2>
<div style="overflow-x: auto;">
<table id="rv-ranking-table" class="rv-ranking-table"></table>
</div>
</section>
<!-- 品种详情 -->
<section id="rv-details-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">📋 品种详情</h2>
<div id="rv-details-grid" class="rv-detail-grid"></div>
</section>
<!-- 板块热度 -->
<section id="rv-sectors-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">🔥 板块热度</h2>
<div id="rv-sectors-grid" class="rv-sector-grid"></div>
</section>
<!-- 页脚 -->
<footer style="text-align: center; padding: 24px 0; color: var(--text-tertiary); font-size: 12px; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 32px;">
<p>本报告由系统自动生成,仅供投资参考,不构成具体投资建议。</p>
<p>投资有风险,入市需谨慎。请结合自身情况独立判断。</p>
</footer>
</div>
<!-- 空状态 -->
<div id="rv-empty-state" class="rv-empty-state">
<div class="rv-empty-icon">📊</div>
<div class="rv-empty-text">请选择日期或点击"复盘与计划"按钮生成分析报告</div>
</div>
</div>
</div>

@ -2189,377 +2189,498 @@ function showAIDetailModal() {
document.getElementById('ai-analysis-modal').classList.add('active');
}
// ==================== 复盘计划功能 ====================
// ==================== 复盘计划功能 V2 ====================
const REVIEW_API_BASE = '/api/v1/review';
const DATA_API_BASE = '/api/v1/data';
let currentReviewDateId = null;
const RV_API_BASE = '/api/v1/review';
let rvCurrentPlanId = null;
let rvCurrentPlanData = null;
function initReviewPlan() {
const reviewDateSelector = document.getElementById('review-date-selector');
const btnReviewPlan = document.getElementById('btn-review-plan');
if (reviewDateSelector) {
reviewDateSelector.addEventListener('change', function(e) {
document.getElementById('rv-date-selector').addEventListener('change', function (e) {
const selectedDate = e.target.value;
if (selectedDate) {
loadReviewByDate(selectedDate);
} else {
hideReviewSummary();
rvLoadPlanByDate(selectedDate);
}
});
}
if (btnReviewPlan) {
btnReviewPlan.addEventListener('click', function() {
handleReviewAndPlan();
document.getElementById('rv-btn-generate').addEventListener('click', function () {
rvHandleGenerate();
});
}
loadReviewDates();
}
function handleReviewAndPlan() {
const now = new Date();
const hour = now.getHours();
const dayOfWeek = now.getDay();
const isTradingTime = checkIsTradingTime(dayOfWeek, hour);
if (isTradingTime) {
alert('当前时间在交易时间内,请在每日收盘后进行复盘与计划。\n可复盘与计划的时间为\n1. 非交易日(周六、周日)\n2. 交易日的 0:00 - 9:00\n3. 交易日的 15:00 - 21:00');
return;
}
document.getElementById('rv-btn-clear').addEventListener('click', function () {
rvHandleClear();
});
checkDataConsistency();
rvLoadLatestPlan();
}
function checkIsTradingTime(dayOfWeek, hour) {
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
if (isWeekend) {
return false;
}
// 交易日 9:00 之前可以复盘
if (hour < 9) {
return false;
}
function rvHandleGenerate() {
const dateSelector = document.getElementById('rv-date-selector');
let reviewDate = dateSelector.value;
// 交易日 15:00 - 21:00 可以复盘
if (hour >= 15 && hour < 21) {
return false;
if (!reviewDate) {
reviewDate = new Date().toISOString().split('T')[0];
}
return true;
rvDoGenerate(reviewDate);
}
function checkDataConsistency() {
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
fetch(`${DATA_API_BASE}/latest-timestamps`)
.then(res => res.json())
.then(data => {
if (!data.success || !data.data || data.data.length === 0) {
alert('交易数据库中没有数据,请先进行数据更新');
return;
}
let latestDate = null;
data.data.forEach(item => {
if (item.last_refresh_at) {
const dateStr = item.last_refresh_at.split('T')[0];
if (!latestDate || dateStr > latestDate) {
latestDate = dateStr;
}
}
});
if (!latestDate) {
alert('交易数据库中没有数据,请先进行数据更新');
return;
}
if (latestDate < todayStr) {
alert(`交易数据库中的最新数据日期为 ${latestDate},与当前日期 ${todayStr} 不一致。\n请先进行数据更新,确保数据是最新的。`);
return;
}
loadOrCreateReviewPlan(todayStr);
function rvDoGenerate(dateStr) {
rvShowLoading();
fetch(`${RV_API_BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_date: dateStr })
})
.catch(err => {
console.error('检查数据一致性失败:', err);
alert('无法获取交易数据状态,请稍后重试');
});
}
function loadOrCreateReviewPlan(todayStr) {
fetch(`${REVIEW_API_BASE}/dates?limit=1`)
.then(res => res.json())
.then(data => {
if (data.success && data.data.length > 0) {
const latestReview = data.data[0];
if (latestReview.review_date === todayStr) {
currentReviewDateId = latestReview.id;
loadReviewSummary(currentReviewDateId);
} else {
createReviewPlan(todayStr);
}
.then(result => {
rvHideLoading();
if (result.success) {
const s = result.data.summary;
alert(
`复盘与计划生成成功!\n` +
`日期: ${s.review_date} ${s.week_day}\n` +
`品种数: ${s.symbol_count}\n` +
`交易机会: ${s.opportunity_count}`
);
rvLoadLatestPlan();
} else {
createReviewPlan(todayStr);
alert('生成失败: ' + (result.message || '未知错误'));
}
})
.catch(err => {
console.error('加载复盘日期失败:', err);
createReviewPlan(todayStr);
rvHideLoading();
console.error('生成复盘计划失败:', err);
alert('生成失败,请稍后重试');
});
}
function createReviewPlan(todayStr) {
const weekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const now = new Date();
const weekDay = weekDays[now.getDay() - 1] || "";
alert('开始执行复盘与交易计划生成...');
function rvHandleClear() {
if (!confirm('确定要清除所有复盘与交易计划数据吗?此操作不可恢复。')) return;
fetch(`${REVIEW_API_BASE}/plans/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
review_date: todayStr,
week_day: weekDay,
rankings: [],
plans: []
})
})
fetch(`${RV_API_BASE}/v2/clear`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
currentReviewDateId = data.data.review_date_id;
loadReviewDates();
loadReviewSummary(currentReviewDateId);
alert('复盘与计划创建成功');
alert('复盘数据已清除');
document.getElementById('rv-date-selector').value = '';
rvCurrentPlanId = null;
rvCurrentPlanData = null;
rvHideAllSections();
document.getElementById('rv-empty-state').style.display = 'flex';
} else {
alert('创建复盘计划失败');
alert(data.message || '清除失败');
}
})
.catch(err => {
console.error('创建复盘计划失败:', err);
alert('创建失败,请稍后重试');
console.error('清除数据失败:', err);
alert('清除失败,请稍后重试');
});
}
function loadReviewDates() {
fetch(`${REVIEW_API_BASE}/dates?limit=20`)
function rvLoadLatestPlan() {
fetch(`${RV_API_BASE}/v2/plans?limit=1`)
.then(res => res.json())
.then(data => {
if (data.success && data.data.length > 0) {
const lastDate = data.data[0];
document.getElementById('review-date-selector').value = lastDate.review_date;
currentReviewDateId = lastDate.id;
loadReviewSummary(lastDate.id);
if (data.success && data.data && data.data.length > 0) {
const plan = data.data[0];
const dateSelector = document.getElementById('rv-date-selector');
if (dateSelector && plan.review_date) {
dateSelector.value = plan.review_date;
}
rvLoadAndRender(plan.id);
} else {
rvHideAllSections();
document.getElementById('rv-empty-state').style.display = 'flex';
}
})
.catch(err => {
console.error('加载复盘日期失败:', err);
console.error('加载最新计划失败:', err);
});
}
function loadReviewByDate(selectedDate) {
fetch(`${REVIEW_API_BASE}/dates?limit=100`)
function rvLoadPlanByDate(date) {
rvShowLoading();
fetch(`${RV_API_BASE}/v2/plan/by-date/${date}`)
.then(res => res.json())
.then(data => {
if (data.success && data.data.length > 0) {
const matchedReview = data.data.find(d => d.review_date === selectedDate);
if (matchedReview) {
currentReviewDateId = matchedReview.id;
loadReviewSummary(currentReviewDateId);
} else {
hideReviewSummary();
document.getElementById('review-summary-content').style.display = 'block';
document.getElementById('review-empty-state').style.display = 'none';
document.getElementById('review-rank-volume').innerHTML = '';
document.getElementById('review-rank-amplitude').innerHTML = '';
document.getElementById('review-rank-change').innerHTML = '';
document.getElementById('review-rank-oi').innerHTML = '';
document.getElementById('review-plan-list').innerHTML = '<div class="plan-note">该日期暂无复盘数据</div>';
}
rvHideLoading();
if (data.success && data.data) {
rvCurrentPlanId = data.data.meta.id;
rvCurrentPlanData = data.data;
rvRenderAll(data.data);
} else {
hideReviewSummary();
rvHideAllSections();
document.getElementById('rv-empty-state').style.display = 'flex';
}
})
.catch(err => {
console.error('加载复盘日期失败:', err);
rvHideLoading();
console.error('按日期加载计划失败:', err);
});
}
function loadReviewSummary(reviewDateId) {
fetch(`${REVIEW_API_BASE}/summary/${reviewDateId}`)
function rvLoadAndRender(planId) {
rvShowLoading();
fetch(`${RV_API_BASE}/v2/plan/${planId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
renderReviewSummary(data.data);
.then(result => {
rvHideLoading();
if (result.success && result.data) {
rvCurrentPlanId = planId;
rvCurrentPlanData = result.data;
rvRenderAll(result.data);
} else {
alert('加载复盘数据失败');
alert('加载计划详情失败');
}
})
.catch(err => {
console.error('加载复盘数据失败:', err);
alert('加载失败');
rvHideLoading();
console.error('加载计划详情失败:', err);
alert('加载失败,请稍后重试');
});
}
function renderReviewSummary(data) {
document.getElementById('review-current-date').textContent = `${data.review_date} (${data.week_day})`;
document.getElementById('review-summary-content').style.display = 'block';
document.getElementById('review-empty-state').style.display = 'none';
function rvRenderAll(data) {
const contentArea = document.getElementById('rv-content-area');
if (contentArea) contentArea.style.display = '';
document.getElementById('rv-empty-state').style.display = 'none';
const { meta, scores, plans, sectors } = data;
renderReviewRankings(data.rankings);
renderReviewPlans(data.plans, data.review_date);
rvRenderHero(meta);
rvRenderStats(meta, scores);
rvRenderRisk(meta);
rvRenderGreenSection(plans, scores);
rvRenderYellowSection(scores);
rvRenderRedSection(scores);
rvRenderRankingTable(scores);
rvRenderDetails(scores);
rvRenderSectors(sectors);
}
function renderReviewRankings(rankings) {
const rankTypes = {
'volume': 'review-rank-volume',
'amplitude': 'review-rank-amplitude',
'change': 'review-rank-change',
'open_interest': 'review-rank-oi'
};
function rvRenderHero(meta) {
const el = document.getElementById('rv-hero-section');
if (!el) return;
for (const [type, elementId] of Object.entries(rankTypes)) {
const container = document.getElementById(elementId);
container.innerHTML = '';
el.innerHTML = `
<h1>${meta.review_date} <span style="font-size: 18px; opacity: 0.8;">${meta.week_day || ''}</span></h1>
<div class="rv-hero-subtitle">${meta.data_basis || ''}</div>
<div class="rv-hero-conclusion">${meta.core_conclusion || ''}</div>
`;
el.style.display = '';
}
if (rankings[type] && rankings[type].items) {
rankings[type].items.forEach(item => {
const row = document.createElement('div');
row.className = 'rank-row';
function rvRenderStats(meta, scores) {
const el = document.getElementById('rv-stats-grid');
if (!el) return;
const isTop = item.rank <= 3;
const changeClass = item.change_pct && item.change_pct.startsWith('+') ? 'up' :
item.change_pct && item.change_pct.startsWith('-') ? 'down' : '';
const top3 = scores.slice(0, 3);
const top3Text = top3.map(s => `${s.symbol} ${s.name}(${s.composite_score}分)`).join('、');
row.innerHTML = `
<div class="rank-rank ${isTop ? 'top' : ''}">${item.rank}</div>
<div class="rank-name">${item.symbol} ${item.name}</div>
<div class="rank-val ${changeClass}">${item.value || item.price || ''}</div>
el.innerHTML = `
<div class="rv-stat-card">
<div class="rv-stat-label">核心结论</div>
<div class="rv-stat-text">${meta.core_conclusion || '-'}</div>
</div>
<div class="rv-stat-card">
<div class="rv-stat-label">TOP 3 机会</div>
<div class="rv-stat-value" style="font-size: 14px;">${top3Text || '-'}</div>
</div>
<div class="rv-stat-card">
<div class="rv-stat-label">市场概览</div>
<div class="rv-stat-text">多头 ${meta.bull_count || 0} | 空头 ${meta.bear_count || 0} | 震荡 ${meta.neutral_count || 0}</div>
</div>
`;
el.style.display = '';
}
container.appendChild(row);
});
function rvRenderRisk(meta) {
const banner = document.getElementById('rv-risk-banner');
const list = document.getElementById('rv-risk-list');
if (!banner || !list) return;
const warnings = meta.risk_warnings || [];
if (warnings.length === 0) {
banner.style.display = 'none';
return;
}
list.innerHTML = warnings.map(w => `<li>${w}</li>`).join('');
banner.style.display = '';
}
function rvScoreBarColor(score) {
if (score > 70) return '#34C759'; // --color-down
if (score > 40) return '#007AFF'; // --color-brand
if (score > 20) return '#FF9F0A'; // --color-neutral
return '#FF3B30'; // --color-up
}
function rvScoreBar(score, label) {
const color = rvScoreBarColor(score);
return `
<div class="rv-score-bar-item">
<span class="rv-score-bar-label">${label}</span>
<div class="rv-score-bar-track">
<div class="rv-score-bar-fill" style="width: ${Math.min(100, score)}%; background: ${color};"></div>
</div>
<span class="rv-score-bar-value" style="color: ${color};">${Math.round(score)}</span>
</div>
`;
}
function rvDirectionTag(tag) {
let cls = 'rv-tag-neutral';
if (tag === '强多' || tag === '偏多') cls = 'rv-tag-bull';
else if (tag === '强空' || tag === '偏空') cls = 'rv-tag-bear';
return `<span class="rv-item-tag ${cls}">${tag || '震荡'}</span>`;
}
function rvRenderGreenSection(plans, scores) {
const section = document.getElementById('rv-green-section');
const grid = document.getElementById('rv-green-grid');
if (!section || !grid) return;
const greenPlans = plans.filter(p => p.category === 'green');
if (greenPlans.length === 0) {
section.style.display = 'none';
return;
}
grid.innerHTML = greenPlans.map(p => {
const score = scores.find(s => s.symbol === p.symbol) || {};
const dirText = p.direction === 'long' ? '做多' : '做空';
const dirColor = p.direction === 'long' ? 'var(--color-down)' : 'var(--color-up)';
return `
<div class="rv-opp-card">
<div class="rv-opp-header">
<span class="rv-opp-symbol">${p.symbol} ${p.name}</span>
<span class="rv-opp-score">${p.composite_score}</span>
</div>
<div class="rv-opp-info">
<div class="rv-opp-info-item"><span class="rv-opp-info-label">方向</span><span style="color: ${dirColor}; font-weight: 600;">${dirText}</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">涨跌幅</span><span>${score.change_pct || 0}%</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">量比</span><span>${score.volume_ratio || '-'}</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">振幅</span><span>${score.amplitude_pct || 0}%</span></div>
</div>
<div class="rv-score-bars">
${rvScoreBar(p.amplitude_score || 0, '振幅')}
${rvScoreBar(p.volume_score || 0, '量能')}
${rvScoreBar(p.trend_score || 0, '趋势')}
${rvScoreBar(p.activity_score || 0, '活跃')}
</div>
<div class="rv-plan-box">
<div class="rv-plan-row"><span>入场区间</span><span>${p.entry_low || '-'} ~ ${p.entry_high || '-'}</span></div>
<div class="rv-plan-row"><span>止损位</span><span style="color: var(--color-up);">${p.stop_loss || '-'}</span></div>
<div class="rv-plan-row"><span>目标一</span><span style="color: var(--color-down);">${p.target1 || '-'}</span></div>
<div class="rv-plan-row"><span>目标二</span><span style="color: var(--color-down);">${p.target2 || '-'}</span></div>
<div class="rv-plan-trigger">触发条件: ${p.trigger || '-'}</div>
</div>
</div>
`;
}).join('');
section.style.display = '';
}
function renderReviewPlans(plans, reviewDate) {
const container = document.getElementById('review-plan-list');
container.innerHTML = '';
function rvRenderYellowSection(scores) {
const section = document.getElementById('rv-yellow-section');
const list = document.getElementById('rv-yellow-list');
if (!section || !list) return;
if (!plans || plans.length === 0) {
container.innerHTML = '<div class="plan-note">暂无交易计划</div>';
const yellowItems = scores.filter(s => s.category === 'yellow');
if (yellowItems.length === 0) {
section.style.display = 'none';
return;
}
plans.forEach((plan, index) => {
const item = document.createElement('div');
item.className = 'plan-list-item';
item.onclick = function() { toggleReviewPlan(this); };
list.innerHTML = yellowItems.map(s => `
<div class="rv-watch-item">
<span class="rv-item-symbol">${s.symbol} ${s.name}</span>
<span class="rv-item-score">${s.composite_score}</span>
${rvDirectionTag(s.direction_tag)}
<span style="color: ${s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct}%</span>
</div>
`).join('');
const planTypeText = plan.plan_type === 'long' ? '做多' : '做空';
const planTypeClass = plan.plan_type === 'long' ? 'long' : 'short';
section.style.display = '';
}
item.innerHTML = `
<div class="plan-list-left">
<span class="plan-list-code">${plan.symbol} ${plan.name}</span>
<span class="plan-badge ${planTypeClass}">${planTypeText}</span>
function rvRenderRedSection(scores) {
const section = document.getElementById('rv-red-section');
const list = document.getElementById('rv-red-list');
if (!section || !list) return;
const redItems = scores.filter(s => s.category === 'red');
if (redItems.length === 0) {
section.style.display = 'none';
return;
}
list.innerHTML = redItems.map(s => `
<div class="rv-avoid-item">
<span class="rv-item-symbol">${s.symbol} ${s.name}</span>
<span class="rv-item-score">${s.composite_score}</span>
${rvDirectionTag(s.direction_tag)}
<span style="color: ${s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct}%</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span class="plan-list-score">${plan.score}</span>
<span class="plan-list-arrow"></span>
`).join('');
section.style.display = '';
}
function rvRenderRankingTable(scores) {
const section = document.getElementById('rv-table-section');
const table = document.getElementById('rv-ranking-table');
if (!section || !table) return;
if (!scores || scores.length === 0) {
section.style.display = 'none';
return;
}
let html = `
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>综合评分</th>
<th>振幅%</th>
<th>涨跌幅%</th>
<th>量比</th>
<th>趋势分</th>
<th>方向</th>
<th>活跃度</th>
</tr>
</thead>
<tbody>
`;
scores.forEach(s => {
const scoreColor = rvScoreBarColor(s.composite_score);
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
html += `
<tr>
<td>${s.rank || '-'}</td>
<td><strong>${s.symbol}</strong> ${s.name}</td>
<td>${s.close_price || '-'}</td>
<td>
<strong>${s.composite_score}</strong>
<div class="rv-table-score-bar">
<div class="rv-table-score-fill" style="width: ${Math.min(100, s.composite_score)}%; background: ${scoreColor};"></div>
</div>
</td>
<td>${s.amplitude_pct || 0}</td>
<td style="color: ${changeColor};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}</td>
<td>${s.volume_ratio || '-'}</td>
<td>${s.trend_score || 0}</td>
<td>${rvDirectionTag(s.direction_tag)}</td>
<td>${s.activity_score || 0}</td>
</tr>
`;
});
const detail = document.createElement('div');
detail.className = 'plan-detail';
html += '</tbody>';
table.innerHTML = html;
section.style.display = '';
}
const generatedDate = plan.created_at ? new Date(plan.created_at).toLocaleString('zh-CN') : reviewDate;
function rvRenderDetails(scores) {
const section = document.getElementById('rv-details-section');
const grid = document.getElementById('rv-details-grid');
if (!section || !grid) return;
detail.innerHTML = `
<div class="plan-card" style="margin-bottom:0">
<div class="plan-ai">
<strong>评分: ${plan.score}/100</strong><br>
多空逻辑: ${plan.logic || '暂无'}<br>
入选理由: ${plan.reason || '暂无'}
if (!scores || scores.length === 0) {
section.style.display = 'none';
return;
}
grid.innerHTML = scores.slice(0, 12).map(s => `
<div class="rv-detail-card">
<div class="rv-detail-title">${s.symbol} ${s.name}</div>
<div class="rv-detail-metrics">
<div class="rv-detail-metric"><span>收盘价</span><span>${s.close_price || '-'}</span></div>
<div class="rv-detail-metric"><span>涨跌幅</span><span style="color: ${s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</span></div>
<div class="rv-detail-metric"><span>振幅</span><span>${s.amplitude_pct || 0}%</span></div>
<div class="rv-detail-metric"><span>量比</span><span>${s.volume_ratio || '-'}</span></div>
<div class="rv-detail-metric"><span>60m趋势</span><span>${s.trend_60m || 0}</span></div>
<div class="rv-detail-metric"><span>15m趋势</span><span>${s.trend_15m || 0}</span></div>
<div class="rv-detail-metric"><span>5m趋势</span><span>${s.trend_5m || 0}</span></div>
<div class="rv-detail-metric"><span>综合评分</span><span style="color: ${rvScoreBarColor(s.composite_score)};">${s.composite_score}</span></div>
</div>
<div class="rv-pivot-tags">
<span class="rv-pivot-tag resist">R2: ${s.r2 || '-'}</span>
<span class="rv-pivot-tag resist">R1: ${s.r1 || '-'}</span>
<span class="rv-pivot-tag pivot">P: ${s.pivot || '-'}</span>
<span class="rv-pivot-tag support">S1: ${s.s1 || '-'}</span>
<span class="rv-pivot-tag support">S2: ${s.s2 || '-'}</span>
</div>
<div class="plan-targets">
<div class="plan-target">
<div class="plan-target-label">入场</div>
<div class="plan-target-val neutral">${plan.entry_price || '-'}</div>
</div>
<div class="plan-target">
<div class="plan-target-label">止损</div>
<div class="plan-target-val red">${plan.stop_loss || '-'}</div>
`).join('');
section.style.display = '';
}
function rvRenderSectors(sectors) {
const section = document.getElementById('rv-sectors-section');
const grid = document.getElementById('rv-sectors-grid');
if (!section || !grid) return;
if (!sectors || sectors.length === 0) {
section.style.display = 'none';
return;
}
grid.innerHTML = sectors.map(sec => {
const heatIcon = sec.heat_level >= 2 ? '🔥' : sec.heat_level === 1 ? '🌡️' : '🧊';
const dirColor = sec.direction === '多头' ? 'var(--color-down)' : sec.direction === '空头' ? 'var(--color-up)' : 'var(--text-tertiary)';
return `
<div class="rv-sector-card">
<div class="rv-sector-header">
<span class="rv-sector-name">${sec.sector_name}</span>
<span class="rv-sector-heat">${heatIcon}</span>
</div>
<div class="plan-target">
<div class="plan-target-label">止盈</div>
<div class="plan-target-val green">${plan.take_profit || '-'}</div>
<div class="rv-sector-info">
均分: <strong>${sec.avg_score}</strong> |
趋势: <span style="color: ${dirColor};">${sec.avg_trend}</span> |
方向: <span style="color: ${dirColor};">${sec.direction}</span>
</div>
<div class="rv-sector-info">龙头: ${sec.leader_symbol} (${sec.leader_score})</div>
<div class="rv-sector-members">
${(sec.members || []).map(m => `<span class="rv-sector-member">${m.name} ${m.score}</span>`).join('')}
</div>
<div class="plan-note">置信度: ${plan.confidence || '-'} | 仓位建议: ${plan.position_suggestion || '-'}</div>
<div class="plan-note" style="margin-top:12px;color:var(--color-ai);font-weight:500;">计划生成时间: ${generatedDate}</div>
</div>
`;
}).join('');
container.appendChild(item);
container.appendChild(detail);
});
section.style.display = '';
}
function toggleReviewPlan(element) {
const detail = element.nextElementSibling;
const isOpen = detail.classList.contains('open');
document.querySelectorAll('#review-plan-list .plan-detail.open').forEach(d => {
d.classList.remove('open');
d.previousElementSibling.classList.remove('expanded');
});
if (!isOpen) {
detail.classList.add('open');
element.classList.add('expanded');
}
function rvShowLoading() {
const el = document.getElementById('rv-loading');
if (el) el.classList.add('active');
}
function hideReviewSummary() {
document.getElementById('review-summary-content').style.display = 'none';
document.getElementById('review-empty-state').style.display = 'flex';
currentReviewDateId = null;
function rvHideLoading() {
const el = document.getElementById('rv-loading');
if (el) el.classList.remove('active');
}
function clearReviewData() {
if (confirm('确定要清除所有复盘与交易计划数据吗?此操作不可恢复。')) {
fetch(`${REVIEW_API_BASE}/clear`, {
method: 'DELETE'
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('复盘数据已清除');
document.getElementById('review-date-selector').value = '';
hideReviewSummary();
loadReviewDates();
} else {
alert(data.message || '清除失败');
}
})
.catch(err => {
console.error('清除数据失败:', err);
alert('清除失败,请稍后重试');
function rvHideAllSections() {
const contentArea = document.getElementById('rv-content-area');
if (contentArea) contentArea.style.display = 'none';
const sections = ['rv-hero-section', 'rv-stats-grid', 'rv-risk-banner', 'rv-green-section', 'rv-yellow-section', 'rv-red-section', 'rv-table-section', 'rv-details-section', 'rv-sectors-section'];
sections.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
}
}
// ==================== 自选品种功能 ====================

File diff suppressed because it is too large Load Diff

@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>复盘计划 - 期货智析</title>
<link rel="stylesheet" href="/static/review_plan.css">
</head>
<body>
<!-- 顶部导航栏 -->
<header class="header">
<div class="logo">期货智析 - 复盘计划</div>
<nav class="nav-items">
<a href="/futures-analysis" class="nav-item">返回分析页面</a>
</nav>
</header>
<!-- 主容器 -->
<main class="container">
<!-- 工具栏 -->
<div class="review-toolbar">
<input type="date" id="date-selector" class="date-selector" />
<button id="btn-generate" class="btn btn-primary">
<span class="btn-icon">📊</span>
<span class="btn-text">复盘与计划</span>
</button>
<button id="btn-clear" class="btn btn-secondary">
<span class="btn-icon">🗑️</span>
<span class="btn-text">清除数据</span>
</button>
</div>
<!-- 加载指示器 -->
<div id="loading-indicator" class="loading-indicator" style="display: none;">
<div class="spinner"></div>
<p class="loading-text">正在生成复盘计划,请稍候...</p>
</div>
<!-- 内容区域 -->
<div id="content-area" class="content-area" style="display: none;">
<!-- Hero 区域 -->
<section id="hero-section" class="hero-section">
<!-- 由 JS 动态填充 -->
</section>
<!-- 统计卡片网格 -->
<section id="stats-grid" class="stats-grid">
<!-- 由 JS 动态填充核心结论、Top3机会、市场概览(多/空/中性数量) -->
</section>
<!-- 风险提示横幅 -->
<section id="risk-banner" class="risk-banner">
<div class="risk-banner-header">
<span class="risk-icon">⚠️</span>
<span class="risk-title">风险提示</span>
</div>
<ul id="risk-list" class="risk-list">
<!-- 由 JS 动态填充 -->
</ul>
</section>
<!-- 粘性导航条 -->
<nav class="sticky-nav" id="sticky-nav">
<a href="#hero-section" class="sticky-nav-link">顶部</a>
<a href="#green-section" class="sticky-nav-link">交易机会</a>
<a href="#yellow-section" class="sticky-nav-link">重点关注</a>
<a href="#red-section" class="sticky-nav-link">规避</a>
<a href="#table-section" class="sticky-nav-link">全品种排名</a>
<a href="#details-section" class="sticky-nav-link">品种详情</a>
<a href="#sectors-section" class="sticky-nav-link">板块热度</a>
</nav>
<!-- 交易机会品种(绿色区域) -->
<section id="green-section" class="content-section green-section">
<div class="section-header">
<span class="section-badge badge-green"></span>
<h2 class="section-title">交易机会品种</h2>
</div>
<div id="green-grid" class="card-grid">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 重点关注品种(黄色区域) -->
<section id="yellow-section" class="content-section yellow-section">
<div class="section-header">
<span class="section-badge badge-yellow"></span>
<h2 class="section-title">重点关注品种</h2>
</div>
<div id="yellow-list" class="item-list">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 规避品种(红色区域) -->
<section id="red-section" class="content-section red-section">
<div class="section-header">
<span class="section-badge badge-red"></span>
<h2 class="section-title">规避品种</h2>
</div>
<div id="red-list" class="item-list">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 全品种排名表格 -->
<section id="table-section" class="content-section table-section">
<div class="section-header">
<h2 class="section-title">全品种排名</h2>
</div>
<div class="table-wrapper">
<table id="ranking-table" class="ranking-table">
<thead>
<tr>
<th>排名</th>
<th>品种</th>
<th>最新价</th>
<th>涨跌幅</th>
<th>成交量</th>
<th>持仓量</th>
<th>趋势</th>
<th>信号</th>
</tr>
</thead>
<tbody>
<!-- 由 JS 动态填充 -->
</tbody>
</table>
</div>
</section>
<!-- 品种详情卡片 -->
<section id="details-section" class="content-section details-section">
<div class="section-header">
<h2 class="section-title">品种详情</h2>
</div>
<div id="details-grid" class="card-grid">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 板块热度图 -->
<section id="sectors-section" class="content-section sectors-section">
<div class="section-header">
<h2 class="section-title">板块热度</h2>
</div>
<div id="sectors-grid" class="sectors-grid">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 页脚免责声明 -->
<footer class="footer-disclaimer">
<p>⚠️ 免责声明:以上内容均由 AI 基于多维因子自动生成,仅供学习参考,不构成任何投资建议。投资有风险,入市需谨慎。</p>
</footer>
</div>
<!-- 空状态(默认显示) -->
<div id="empty-state" class="empty-state">
<div class="empty-icon">📊</div>
<p class="empty-title">暂无复盘数据</p>
<p class="empty-text">请选择复盘日期,然后点击"复盘与计划"按钮生成数据</p>
</div>
</main>
<script src="/static/review_plan.js"></script>
</body>
</html>

@ -0,0 +1,680 @@
const API_BASE = '/api/v1/review';
const DATA_API_BASE = '/api/v1/data';
let currentPlanId = null;
let currentPlanData = null;
// ==================== 初始化 ====================
document.addEventListener('DOMContentLoaded', function () {
setupEventListeners();
loadLatestPlan();
});
function setupEventListeners() {
document.getElementById('date-selector').addEventListener('change', function (e) {
const selectedDate = e.target.value;
if (selectedDate) {
loadPlanByDate(selectedDate);
}
});
document.getElementById('btn-generate').addEventListener('click', function () {
handleGenerate();
});
document.getElementById('btn-clear').addEventListener('click', function () {
handleClear();
});
}
// ==================== 生成复盘 ====================
function handleGenerate() {
const dateSelector = document.getElementById('date-selector');
let reviewDate = dateSelector.value;
if (!reviewDate) {
// 未选日期时默认使用当天
reviewDate = new Date().toISOString().split('T')[0];
}
doGenerate(reviewDate);
}
function doGenerate(dateStr) {
showLoading();
fetch(`${API_BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_date: dateStr })
})
.then(res => res.json())
.then(result => {
hideLoading();
if (result.success) {
const s = result.data.summary;
alert(
`复盘与计划生成成功!\n` +
`日期: ${s.review_date} ${s.week_day}\n` +
`品种数: ${s.symbol_count}\n` +
`交易机会: ${s.opportunity_count}`
);
loadLatestPlan();
} else {
alert('生成失败: ' + (result.message || '未知错误'));
}
})
.catch(err => {
hideLoading();
console.error('生成复盘计划失败:', err);
alert('生成失败,请稍后重试');
});
}
// ==================== 清除数据 ====================
function handleClear() {
if (!confirm('确定要清除所有复盘与交易计划数据吗?此操作不可恢复。')) return;
fetch(`${API_BASE}/v2/clear`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
alert('复盘数据已清除');
document.getElementById('date-selector').value = '';
currentPlanId = null;
currentPlanData = null;
hideAllSections();
showSection('empty-state');
loadLatestPlan();
} else {
alert(data.message || '清除失败');
}
})
.catch(err => {
console.error('清除数据失败:', err);
alert('清除失败,请稍后重试');
});
}
// ==================== 加载数据 ====================
function loadLatestPlan() {
fetch(`${API_BASE}/v2/plans?limit=1`)
.then(res => res.json())
.then(data => {
if (data.success && data.data && data.data.length > 0) {
const plan = data.data[0];
const dateSelector = document.getElementById('date-selector');
if (dateSelector && plan.review_date) {
dateSelector.value = plan.review_date;
}
loadAndRender(plan.id);
} else {
hideAllSections();
showSection('empty-state');
}
})
.catch(err => {
console.error('加载最新计划失败:', err);
});
}
function loadPlanByDate(date) {
showLoading();
fetch(`${API_BASE}/v2/plan/by-date/${date}`)
.then(res => res.json())
.then(data => {
hideLoading();
if (data.success && data.data) {
currentPlanId = data.data.meta.id;
currentPlanData = data.data;
renderAll(data.data);
} else {
hideAllSections();
showSection('empty-state');
}
})
.catch(err => {
hideLoading();
console.error('按日期加载计划失败:', err);
});
}
function loadAndRender(planId) {
showLoading();
fetch(`${API_BASE}/v2/plan/${planId}`)
.then(res => res.json())
.then(result => {
hideLoading();
if (result.success && result.data) {
currentPlanId = planId;
currentPlanData = result.data;
renderAll(result.data);
} else {
alert('加载计划详情失败');
}
})
.catch(err => {
hideLoading();
console.error('加载计划详情失败:', err);
alert('加载失败,请稍后重试');
});
}
// ==================== 渲染总入口 ====================
function renderAll(data) {
// 显示内容区域
const contentArea = document.getElementById('content-area');
if (contentArea) contentArea.style.display = '';
hideSection('empty-state');
const { meta, scores, plans, sectors } = data;
renderHero(meta);
renderStats(meta, scores);
renderRisk(meta);
renderGreenSection(plans, scores);
renderYellowSection(scores);
renderRedSection(scores);
renderRankingTable(scores);
renderDetails(scores);
renderSectors(sectors);
}
// ==================== Hero 区域 ====================
function renderHero(meta) {
const el = document.getElementById('hero-section');
if (!el) return;
el.innerHTML = `
<div class="hero-title">
<h1>${meta.review_date} <span class="hero-weekday">${meta.week_day || ''}</span></h1>
</div>
<div class="hero-subtitle">${meta.data_basis || ''}</div>
<div class="hero-conclusion">${meta.core_conclusion || ''}</div>
`;
showSection('hero-section');
}
// ==================== 统计卡片 ====================
function renderStats(meta, scores) {
const el = document.getElementById('stats-grid');
if (!el) return;
const top3 = (scores || []).slice(0, 3);
const top3Html = top3.map((s, i) =>
`<span class="stat-top-item">${i + 1}. ${s.symbol} ${s.name || ''} <b>${s.composite_score}</b></span>`
).join('');
const bullCount = meta.bull_count || 0;
const bearCount = meta.bear_count || 0;
const neutralCount = meta.neutral_count || 0;
const oppCount = meta.opportunity_count || 0;
el.innerHTML = `
<div class="stat-card">
<div class="stat-card-label">核心结论</div>
<div class="stat-card-value">${meta.core_conclusion || '-'}</div>
</div>
<div class="stat-card">
<div class="stat-card-label">综合评分 Top 3</div>
<div class="stat-card-value stat-top-list">${top3Html || '暂无'}</div>
</div>
<div class="stat-card">
<div class="stat-card-label">市场概览</div>
<div class="stat-card-value stat-overview">
<span class="stat-bull">多头 <b>${bullCount}</b></span>
<span class="stat-bear">空头 <b>${bearCount}</b></span>
<span class="stat-neutral">中性 <b>${neutralCount}</b></span>
<span class="stat-opp">机会 <b>${oppCount}</b></span>
</div>
</div>
`;
showSection('stats-grid');
}
// ==================== 风险提示 ====================
function renderRisk(meta) {
const el = document.getElementById('risk-banner');
if (!el) return;
const warnings = meta.risk_warnings || [];
if (warnings.length === 0) {
el.innerHTML = `<div class="risk-title">⚠️ 风险提示</div><div class="risk-list">暂无特别风险提示</div>`;
} else {
const items = warnings.map(w => `<li class="risk-item">${w}</li>`).join('');
el.innerHTML = `
<div class="risk-title"> 风险提示</div>
<ul class="risk-list">${items}</ul>
`;
}
showSection('risk-banner');
}
// ==================== 绿色机会区 ====================
function renderGreenSection(plans, scores) {
const el = document.getElementById('green-section');
if (!el) return;
if (!plans || plans.length === 0) {
el.innerHTML = `<div class="section-title green-title">🟢 交易机会(绿色区)</div><div class="empty-hint">暂无交易机会</div>`;
showSection('green-section');
return;
}
const scoreMap = {};
(scores || []).forEach(s => { scoreMap[s.symbol] = s; });
const cards = plans.map(plan => {
const s = scoreMap[plan.symbol] || {};
const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short';
const dirText = plan.direction === 'long' ? '做多' : '做空';
return `
<div class="opp-card green-card">
<div class="opp-card-header">
<a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${plan.symbol}','${plan.name || ''}')">${plan.symbol} ${plan.name || ''}</a>
<span class="score-badge">${plan.composite_score}</span>
</div>
<div class="opp-card-rows">
<div class="opp-row"><span class="opp-label">方向</span><span class="${dirClass}">${dirText}</span></div>
<div class="opp-row"><span class="opp-label">收盘价</span><span>${formatPrice(s.close_price)}</span></div>
<div class="opp-row"><span class="opp-label">涨跌幅</span>${formatChange(s.change_pct)}</div>
<div class="opp-row"><span class="opp-label">量比</span><span>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
</div>
<div class="score-bars">
<div class="score-bar-row"><span class="score-bar-label">振幅</span>${scoreBar(plan.amplitude_score)}</div>
<div class="score-bar-row"><span class="score-bar-label">量能</span>${scoreBar(plan.volume_score)}</div>
<div class="score-bar-row"><span class="score-bar-label">趋势</span>${scoreBar(plan.trend_score)}</div>
<div class="score-bar-row"><span class="score-bar-label">活跃度</span>${scoreBar(plan.activity_score)}</div>
</div>
<div class="plan-box">
<div class="plan-box-row"><span class="plan-box-label">入场区间</span><span class="plan-box-val">${formatPrice(plan.entry_low)} - ${formatPrice(plan.entry_high)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">止损</span><span class="plan-box-val stop">${formatPrice(plan.stop_loss)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">目标1</span><span class="plan-box-val target">${formatPrice(plan.target1)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">目标2</span><span class="plan-box-val target">${formatPrice(plan.target2)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">触发条件</span><span class="plan-box-val trigger">${plan.trigger || '-'}</span></div>
</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="section-title green-title">🟢 交易机会绿色区</div>
<div class="opp-grid">${cards}</div>
`;
showSection('green-section');
}
// ==================== 黄色关注区 ====================
function renderYellowSection(scores) {
const el = document.getElementById('yellow-section');
if (!el) return;
const yellowItems = (scores || []).filter(s => s.category === 'yellow');
if (yellowItems.length === 0) {
el.innerHTML = `<div class="section-title yellow-title">🟡 关注列表(黄色区)</div><div class="empty-hint">暂无关注品种</div>`;
showSection('yellow-section');
return;
}
const items = yellowItems.map(s => `
<div class="watch-item yellow-item">
<div class="watch-left">
<a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${s.symbol}','${s.name || ''}')">${s.symbol} ${s.name || ''}</a>
${directionTag(s.direction_tag)}
</div>
<div class="watch-mid">
${scoreBar(s.composite_score)}
</div>
<div class="watch-right">
<span class="watch-score">${s.composite_score}</span>
${formatChange(s.change_pct)}
</div>
</div>
`).join('');
el.innerHTML = `
<div class="section-title yellow-title">🟡 关注列表黄色区</div>
<div class="watch-list">${items}</div>
`;
showSection('yellow-section');
}
// ==================== 红色风险区 ====================
function renderRedSection(scores) {
const el = document.getElementById('red-section');
if (!el) return;
const redItems = (scores || []).filter(s => s.category === 'red');
if (redItems.length === 0) {
el.innerHTML = `<div class="section-title red-title">🔴 规避列表(红色区)</div><div class="empty-hint">暂无规避品种</div>`;
showSection('red-section');
return;
}
const items = redItems.map(s => `
<div class="watch-item red-item">
<div class="watch-left">
<a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${s.symbol}','${s.name || ''}')">${s.symbol} ${s.name || ''}</a>
${directionTag(s.direction_tag)}
</div>
<div class="watch-mid">
${scoreBar(s.composite_score)}
</div>
<div class="watch-right">
<span class="watch-score">${s.composite_score}</span>
${formatChange(s.change_pct)}
</div>
</div>
`).join('');
el.innerHTML = `
<div class="section-title red-title">🔴 规避列表红色区</div>
<div class="watch-list">${items}</div>
`;
showSection('red-section');
}
// ==================== 排名表格 ====================
function renderRankingTable(scores) {
const el = document.getElementById('ranking-table');
if (!el) return;
if (!scores || scores.length === 0) {
el.innerHTML = `<div class="section-title">📊 综合排名</div><div class="empty-hint">暂无排名数据</div>`;
showSection('ranking-table');
return;
}
const rows = scores.map((s, i) => {
const rank = s.rank || (i + 1);
const rankClass = rank <= 3 ? 'rank-top' : '';
return `
<tr>
<td class="${rankClass}">${rank}</td>
<td><a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${s.symbol}','${s.name || ''}')">${s.symbol}</a></td>
<td>${s.name || ''}</td>
<td>${formatPrice(s.close_price)}</td>
<td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
<td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
<td>${formatChange(s.change_pct)}</td>
<td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<td>${s.trend_score != null ? s.trend_score : '-'}</td>
<td>${directionTag(s.direction_tag)}</td>
<td>${s.activity_score != null ? s.activity_score : '-'}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="section-title">📊 综合排名</div>
<div class="table-wrap">
<table class="ranking-tbl">
<thead>
<tr>
<th>#</th>
<th>代码</th>
<th>名称</th>
<th>收盘价</th>
<th>综合评分</th>
<th>振幅%</th>
<th>涨跌幅</th>
<th>量比</th>
<th>趋势</th>
<th>方向</th>
<th>活跃度</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
showSection('ranking-table');
}
// ==================== 品种详情卡片 ====================
function renderDetails(scores) {
const el = document.getElementById('details-section');
if (!el) return;
if (!scores || scores.length === 0) {
el.innerHTML = `<div class="section-title">📋 品种详情</div><div class="empty-hint">暂无详情数据</div>`;
showSection('details-section');
return;
}
const cards = scores.map(s => {
const pivotTags = [];
if (s.pivot != null) pivotTags.push(`<span class="pivot-tag pivot">Pivot ${formatPrice(s.pivot)}</span>`);
if (s.r1 != null) pivotTags.push(`<span class="pivot-tag r1">R1 ${formatPrice(s.r1)}</span>`);
if (s.r2 != null) pivotTags.push(`<span class="pivot-tag r2">R2 ${formatPrice(s.r2)}</span>`);
if (s.s1 != null) pivotTags.push(`<span class="pivot-tag s1">S1 ${formatPrice(s.s1)}</span>`);
if (s.s2 != null) pivotTags.push(`<span class="pivot-tag s2">S2 ${formatPrice(s.s2)}</span>`);
return `
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-symbol">${s.symbol} ${s.name || ''}</span>
<span class="detail-score">${s.composite_score}</span>
</div>
<div class="detail-metrics">
<div class="detail-metric"><span class="dm-label">收盘价</span><span class="dm-val">${formatPrice(s.close_price)}</span></div>
<div class="detail-metric"><span class="dm-label">振幅</span><span class="dm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">涨跌幅</span><span class="dm-val">${formatChange(s.change_pct)}</span></div>
<div class="detail-metric"><span class="dm-label">量比</span><span class="dm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">振幅评分</span><span class="dm-val">${s.amplitude_score != null ? s.amplitude_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">量能评分</span><span class="dm-val">${s.volume_score != null ? s.volume_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">趋势评分</span><span class="dm-val">${s.trend_score != null ? s.trend_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">活跃度</span><span class="dm-val">${s.activity_score != null ? s.activity_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">60m趋势</span><span class="dm-val">${s.trend_60m != null ? s.trend_60m : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">15m趋势</span><span class="dm-val">${s.trend_15m != null ? s.trend_15m : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">5m趋势</span><span class="dm-val">${s.trend_5m != null ? s.trend_5m : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">方向</span><span class="dm-val">${directionTag(s.direction_tag)}</span></div>
</div>
<div class="detail-pivots">${pivotTags.join('')}</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="section-title">📋 品种详情</div>
<div class="detail-grid">${cards}</div>
`;
showSection('details-section');
}
// ==================== 板块热力 ====================
function renderSectors(sectors) {
const el = document.getElementById('sectors-section');
if (!el) return;
if (!sectors || sectors.length === 0) {
el.innerHTML = `<div class="section-title">🔥 板块热力</div><div class="empty-hint">暂无板块数据</div>`;
showSection('sectors-section');
return;
}
const cards = sectors.map(sec => {
const heatLevel = sec.heat_level || 0;
const fireIcons = '🔥'.repeat(Math.min(heatLevel, 5));
const iceIcons = heatLevel <= 1 ? '🧊' : '';
const heatIndicator = heatLevel >= 3 ? fireIcons : (heatLevel <= 1 ? iceIcons : fireIcons);
const members = (sec.members || []).map(m =>
`<span class="sector-member">${m.symbol} ${m.name || ''} <b>${m.score}</b></span>`
).join('');
return `
<div class="sector-card heat-${heatLevel}">
<div class="sector-header">
<span class="sector-name">${sec.sector_name}</span>
<span class="sector-heat">${heatIndicator}</span>
</div>
<div class="sector-stats">
<div class="sector-stat"><span class="ss-label">均分</span><span class="ss-val">${sec.avg_score != null ? sec.avg_score.toFixed(1) : '-'}</span></div>
<div class="sector-stat"><span class="ss-label">方向</span><span class="ss-val">${sec.direction || '-'}</span></div>
<div class="sector-stat"><span class="ss-label">领涨</span><span class="ss-val">${sec.leader_symbol || '-'} ${sec.leader_score != null ? sec.leader_score.toFixed(1) : ''}</span></div>
</div>
<div class="sector-members">${members || '暂无成员'}</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="section-title">🔥 板块热力</div>
<div class="sector-grid">${cards}</div>
`;
showSection('sectors-section');
}
// ==================== 品种详情弹窗 ====================
function showDetail(symbol, name) {
const scores = currentPlanData ? currentPlanData.scores : [];
const s = scores.find(x => x.symbol === symbol);
if (!s) return;
const modal = document.getElementById('detail-modal');
if (!modal) return;
document.getElementById('modal-title').textContent = `${symbol} ${name || s.name || ''}`;
const body = document.getElementById('modal-body');
body.innerHTML = `
<div class="modal-section">
<div class="modal-section-title">基本信息</div>
<div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">收盘价</span><span class="mm-val">${formatPrice(s.close_price)}</span></div>
<div class="modal-metric"><span class="mm-label">振幅</span><span class="mm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">涨跌幅</span><span class="mm-val">${formatChange(s.change_pct)}</span></div>
<div class="modal-metric"><span class="mm-label">量比</span><span class="mm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">综合评分</span><span class="mm-val"><b>${s.composite_score}</b></span></div>
<div class="modal-metric"><span class="mm-label">排名</span><span class="mm-val">#${s.rank || '-'}</span></div>
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">评分明细</div>
<div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">振幅评分</span><span class="mm-val">${s.amplitude_score}</span></div>
<div class="modal-metric"><span class="mm-label">量能评分</span><span class="mm-val">${s.volume_score}</span></div>
<div class="modal-metric"><span class="mm-label">趋势评分</span><span class="mm-val">${s.trend_score}</span></div>
<div class="modal-metric"><span class="mm-label">活跃度</span><span class="mm-val">${s.activity_score}</span></div>
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">多周期趋势</div>
<div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">60分钟</span><span class="mm-val">${s.trend_60m != null ? s.trend_60m : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">15分钟</span><span class="mm-val">${s.trend_15m != null ? s.trend_15m : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">5分钟</span><span class="mm-val">${s.trend_5m != null ? s.trend_5m : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">方向</span><span class="mm-val">${directionTag(s.direction_tag)}</span></div>
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">枢轴点位</div>
<div class="modal-pivots">
${s.r2 != null ? `<span class="pivot-tag r2">R2 ${formatPrice(s.r2)}</span>` : ''}
${s.r1 != null ? `<span class="pivot-tag r1">R1 ${formatPrice(s.r1)}</span>` : ''}
${s.pivot != null ? `<span class="pivot-tag pivot">Pivot ${formatPrice(s.pivot)}</span>` : ''}
${s.s1 != null ? `<span class="pivot-tag s1">S1 ${formatPrice(s.s1)}</span>` : ''}
${s.s2 != null ? `<span class="pivot-tag s2">S2 ${formatPrice(s.s2)}</span>` : ''}
</div>
</div>
`;
modal.classList.add('show');
}
function closeModal() {
const modal = document.getElementById('detail-modal');
if (modal) modal.classList.remove('show');
}
// ==================== 工具函数 ====================
function scoreBarColor(score) {
if (score > 70) return 'var(--gn)';
if (score > 40) return 'var(--ac)';
if (score > 20) return 'var(--yw)';
return 'var(--rd)';
}
function scoreBar(score) {
const color = scoreBarColor(score);
const pct = Math.min(Math.max(score, 0), 100);
return `<div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${color}"></div><span class="score-bar-text">${score}</span></div>`;
}
function directionTag(tag) {
if (!tag) return '<span class="dir-badge">-</span>';
let cls = 'dir-neutral';
if (tag.includes('强多') || tag.includes('多头共振')) cls = 'dir-strong-long';
else if (tag.includes('多')) cls = 'dir-long';
else if (tag.includes('强空') || tag.includes('空头共振')) cls = 'dir-strong-short';
else if (tag.includes('空')) cls = 'dir-short';
return `<span class="dir-badge ${cls}">${tag}</span>`;
}
function formatPrice(price) {
if (price == null || isNaN(price)) return '-';
return Number(price).toFixed(2);
}
function formatChange(pct) {
if (pct == null || isNaN(pct)) return '<span class="change-neutral">-</span>';
const val = Number(pct);
const sign = val > 0 ? '+' : '';
const cls = val > 0 ? 'change-up' : (val < 0 ? 'change-down' : 'change-neutral');
return `<span class="${cls}">${sign}${val.toFixed(2)}%</span>`;
}
function showSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = '';
}
function hideSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
function hideAllSections() {
const contentArea = document.getElementById('content-area');
if (contentArea) contentArea.style.display = 'none';
const ids = [
'hero-section', 'stats-grid', 'risk-banner',
'green-section', 'yellow-section', 'red-section',
'ranking-table', 'details-section', 'sectors-section',
'summary-content'
];
ids.forEach(id => hideSection(id));
}
function showLoading() {
let overlay = document.getElementById('loading-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'loading-overlay';
overlay.innerHTML = '<div class="loading-spinner">⏳ 加载中...</div>';
document.body.appendChild(overlay);
}
overlay.style.display = 'flex';
}
function hideLoading() {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
}

Binary file not shown.
Loading…
Cancel
Save