From 94d06fea27833d48208e03744b92ffa42bf32402 Mon Sep 17 00:00:00 2001 From: Lxy Date: Mon, 15 Jun 2026 00:37:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=8D=E7=9B=98=E4=B8=8E?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=A8=A1=E5=9D=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AUTH_GUIDE.md | 273 +++++ app/__pycache__/main.cpython-311.pyc | Bin 8478 -> 8159 bytes app/analysis_db.py | 6 +- app/analysis_models.py | 117 ++ app/api/review_plan.py | 615 +++++++++++ app/main.py | 6 - app/services/plan_generator.py | 724 ++++++++++++ app/static/futures_analysis.html | 295 +++-- app/static/futures_analysis.js | 697 +++++++----- app/static/review_plan.css | 1525 ++++++++++++++++++++++++++ app/static/review_plan.html | 171 +++ app/static/review_plan.js | 680 ++++++++++++ data/futures_analysis.db | Bin 6860800 -> 6930432 bytes 13 files changed, 4723 insertions(+), 386 deletions(-) create mode 100644 AUTH_GUIDE.md create mode 100644 app/api/review_plan.py create mode 100644 app/services/plan_generator.py create mode 100644 app/static/review_plan.css create mode 100644 app/static/review_plan.html create mode 100644 app/static/review_plan.js diff --git a/AUTH_GUIDE.md b/AUTH_GUIDE.md new file mode 100644 index 0000000..a1e4b2e --- /dev/null +++ b/AUTH_GUIDE.md @@ -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 +``` + +**响应:** +```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 +``` + +### 4. 用户登出 + +**POST** `/api/v1/auth/logout` + +**请求头:** +``` +Authorization: Bearer +``` + +## 🗄️ 数据库表 + +系统创建了两个新表: + +### 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) diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc index 1969ebb044ccef1fd7e019625714d28be193326b..0e8c451ec6cea8bab161a69b065d77f6b632604b 100644 GIT binary patch delta 215 zcmbQ|bl;wLIWI340}zN`(aVyZ$ScY8g>|FGW>#0t6uoRFkVKJG3S$aWj(wCp6GN(f zmIFu(1XA=<#Ztvn#Mc-sV_{%e4a5+@$dJMm491#L#BT7oGe`>;_}hgB7^aG)il>OLFmr6 z(QNsrOM0Krp7CPc!lxaRUM}4Fa_*uk0f?G-u$o3uDS2dp_=3dr)S_6R#a%#L6apkB z?-p-kO#(6|Om>r4&6);eO`H5pB9PH(vxnq5Mx!vGz%9PayyTqHl+^g5{L+%tqFZbb ux+oGTSriQ-KxP(!j44W+tSqg~)edBW0<3uX<_PJ3%#%OKDopm1p9lc)MC diff --git a/app/analysis_db.py b/app/analysis_db.py index 26f98ca..f530dfc 100644 --- a/app/analysis_db.py +++ b/app/analysis_db.py @@ -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) diff --git a/app/analysis_models.py b/app/analysis_models.py index ca1a651..1e3b834 100644 --- a/app/analysis_models.py +++ b/app/analysis_models.py @@ -154,3 +154,120 @@ class TradingPlan(AnalysisBase): def __repr__(self): return f"" + + +# ==================== 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"" + + +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"" + + +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"" + + +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"" diff --git a/app/api/review_plan.py b/app/api/review_plan.py new file mode 100644 index 0000000..53410bc --- /dev/null +++ b/app/api/review_plan.py @@ -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)}" + } diff --git a/app/main.py b/app/main.py index abf9d9b..0380f0b 100644 --- a/app/main.py +++ b/app/main.py @@ -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"} diff --git a/app/services/plan_generator.py b/app/services/plan_generator.py new file mode 100644 index 0000000..f208a18 --- /dev/null +++ b/app/services/plan_generator.py @@ -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 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, + } diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html index 2a13940..bdd9d51 100644 --- a/app/static/futures_analysis.html +++ b/app/static/futures_analysis.html @@ -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; } + } @@ -621,56 +689,101 @@ - +
-
- - +
-