"""复盘计划 API 路由 - V1 复盘日期/排名/计划 + V2 评分/交易计划/板块热度""" import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.analysis import ( ReviewDate, ReviewPlanV2, SectorHeat, SymbolRanking, SymbolScoreV2, TradingPlan, TradingPlanV2, ) from app.services.plan_generator import PlanGenerator, load_symbols_config logger = logging.getLogger(__name__) router = APIRouter(prefix="/review", tags=["复盘计划"]) class GeneratePlanRequest(BaseModel): """生成V2交易计划请求体。""" review_date: str # ==================== V1 复盘日期管理 ==================== @router.get("/dates") async def get_review_dates( limit: int = 10, db: AsyncSession = Depends(get_db), ): """获取复盘日期列表。""" stmt = select(ReviewDate).order_by(ReviewDate.review_date.desc()).limit(limit) result = await db.execute(stmt) dates = result.scalars().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") async def create_review_date( review_date: str, week_day: str | None = None, db: AsyncSession = Depends(get_db), ): """创建复盘日期。""" stmt = select(ReviewDate).where(ReviewDate.review_date == review_date) result = await db.execute(stmt) existing = result.scalar_one_or_none() 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 ValueError: week_day = "" new_date = ReviewDate(review_date=review_date, week_day=week_day) db.add(new_date) await db.flush() await 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}") async def get_rankings( review_date_id: int, db: AsyncSession = Depends(get_db), ): """获取指定日期的品种排名。""" stmt_date = select(ReviewDate).where(ReviewDate.id == review_date_id) result_date = await db.execute(stmt_date) review_date = result_date.scalar_one_or_none() if not review_date: raise HTTPException(status_code=404, detail="复盘日期不存在") stmt = ( select(SymbolRanking) .where(SymbolRanking.review_date_id == review_date_id) .order_by(SymbolRanking.rank_type, SymbolRanking.rank) ) result = await db.execute(stmt) rankings = result.scalars().all() rank_types = ["volume", "amplitude", "change", "open_interest"] rank_type_names = { "volume": "成交量排名", "amplitude": "振幅排名", "change": "涨跌幅排名", "open_interest": "持仓量排名", } result_data: dict = {rt: {"title": rank_type_names.get(rt, rt), "items": []} for rt in rank_types} for r in rankings: if r.rank_type in result_data: result_data[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_data, }, } @router.post("/rankings") async def create_or_update_rankings( review_date_id: int, rankings: list[dict], db: AsyncSession = Depends(get_db), ): """创建或更新品种排名。""" stmt_date = select(ReviewDate).where(ReviewDate.id == review_date_id) result_date = await db.execute(stmt_date) review_date = result_date.scalar_one_or_none() if not review_date: raise HTTPException(status_code=404, detail="复盘日期不存在") from sqlalchemy import delete as sa_delete await db.execute( sa_delete(SymbolRanking).where(SymbolRanking.review_date_id == review_date_id) ) created: list[dict] = [] 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) await db.flush() created.append({ "id": new_ranking.id, "symbol": new_ranking.symbol, "rank_type": new_ranking.rank_type, "rank": new_ranking.rank, }) return {"success": True, "data": created} # ==================== V1 交易计划 ==================== @router.get("/plans/{review_date_id}") async def get_trading_plans( review_date_id: int, db: AsyncSession = Depends(get_db), ): """获取指定日期的交易计划。""" stmt_date = select(ReviewDate).where(ReviewDate.id == review_date_id) result_date = await db.execute(stmt_date) review_date = result_date.scalar_one_or_none() if not review_date: raise HTTPException(status_code=404, detail="复盘日期不存在") stmt = ( select(TradingPlan) .where(TradingPlan.review_date_id == review_date_id) .order_by(TradingPlan.score.desc()) ) result = await db.execute(stmt) plans = result.scalars().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") async def create_or_update_plans( review_date_id: int, plans: list[dict], db: AsyncSession = Depends(get_db), ): """创建或更新交易计划。""" stmt_date = select(ReviewDate).where(ReviewDate.id == review_date_id) result_date = await db.execute(stmt_date) review_date = result_date.scalar_one_or_none() if not review_date: raise HTTPException(status_code=404, detail="复盘日期不存在") from sqlalchemy import delete as sa_delete await db.execute( sa_delete(TradingPlan).where(TradingPlan.review_date_id == review_date_id) ) created: list[dict] = [] 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) await db.flush() created.append({ "id": new_plan.id, "symbol": new_plan.symbol, "plan_type": new_plan.plan_type, "score": new_plan.score, }) return {"success": True, "data": created} @router.post("/plans/batch") async def batch_create_review_data( review_data: dict, db: AsyncSession = Depends(get_db), ): """批量创建复盘数据(日期+排名+计划)。""" review_date_str = review_data.get("review_date") week_day = review_data.get("week_day") stmt = select(ReviewDate).where(ReviewDate.review_date == review_date_str) result = await db.execute(stmt) existing = result.scalar_one_or_none() 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 ValueError: week_day = "" new_date = ReviewDate(review_date=review_date_str, week_day=week_day) db.add(new_date) await db.flush() await db.refresh(new_date) review_date_id = new_date.id rankings = review_data.get("rankings", []) if rankings: from sqlalchemy import delete as sa_delete await db.execute( sa_delete(SymbolRanking).where(SymbolRanking.review_date_id == review_date_id) ) for r in rankings: db.add(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"), )) plans = review_data.get("plans", []) if plans: from sqlalchemy import delete as sa_delete await db.execute( sa_delete(TradingPlan).where(TradingPlan.review_date_id == review_date_id) ) for p in plans: db.add(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"), )) await db.flush() 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}") async def get_review_summary( review_date_id: int, db: AsyncSession = Depends(get_db), ): """获取复盘摘要(包含排名和计划的完整数据)。""" stmt_date = select(ReviewDate).where(ReviewDate.id == review_date_id) result_date = await db.execute(stmt_date) review_date = result_date.scalar_one_or_none() if not review_date: raise HTTPException(status_code=404, detail="复盘日期不存在") stmt_rankings = ( select(SymbolRanking) .where(SymbolRanking.review_date_id == review_date_id) .order_by(SymbolRanking.rank_type, SymbolRanking.rank) ) result_rankings = await db.execute(stmt_rankings) rankings = result_rankings.scalars().all() stmt_plans = ( select(TradingPlan) .where(TradingPlan.review_date_id == review_date_id) .order_by(TradingPlan.score.desc()) ) result_plans = await db.execute(stmt_plans) plans = result_plans.scalars().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: dict = {rt: {"title": rank_type_names.get(rt, rt), "items": []} for rt in rank_types} 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") async def clear_review_data( db: AsyncSession = Depends(get_db), ): """清除所有V1复盘与交易计划数据。""" try: from sqlalchemy import delete as sa_delete await db.execute(sa_delete(TradingPlan)) await db.execute(sa_delete(SymbolRanking)) await db.execute(sa_delete(ReviewDate)) await db.flush() return {"success": True, "message": "复盘数据已清除"} except Exception as e: return {"success": False, "message": f"清除失败: {e!s}"} # ==================== V2 生成交易计划 ==================== @router.post("/generate") async def generate_review_plan( req: GeneratePlanRequest, db: AsyncSession = Depends(get_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()] symbols_config = load_symbols_config() generator = PlanGenerator() plan_data = await generator.generate(db, symbols_config) # 保存计划到数据库 summary = plan_data.get("summary", {}) plan_v2 = ReviewPlanV2( review_date=review_date_str, week_day=week_day, data_basis=summary.get("data_basis"), core_conclusion=summary.get("core_conclusion"), bull_count=summary.get("bull_count"), bear_count=summary.get("bear_count"), neutral_count=summary.get("neutral_count"), opportunity_count=summary.get("opportunity_count"), risk_warnings=summary.get("risk_warnings"), actual_data_date=summary.get("actual_data_date"), ) db.add(plan_v2) await db.flush() # 保存品种评分 for s in plan_data.get("scores", []): score_v2 = SymbolScoreV2( review_date_id=plan_v2.id, symbol=s.get("symbol"), name=s.get("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=s.get("pivots", {}).get("pivot"), r1=s.get("pivots", {}).get("r1"), r2=s.get("pivots", {}).get("r2"), s1=s.get("pivots", {}).get("s1"), s2=s.get("pivots", {}).get("s2"), rank=s.get("rank"), data_date=s.get("data_date"), ) db.add(score_v2) # 保存交易计划 for p in plan_data.get("plans", []): plan_v2_item = TradingPlanV2( review_date_id=plan_v2.id, symbol=p.get("symbol"), name=p.get("name"), direction=p.get("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_v2_item) # 保存板块热度 for sec in plan_data.get("sectors", []): sector_heat = SectorHeat( review_date_id=plan_v2.id, sector_name=sec.get("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_heat) await db.flush() return { "success": True, "data": { "plan_id": plan_v2.id, "summary": { "review_date": review_date_str, "week_day": week_day, "core_conclusion": summary.get("core_conclusion"), "bull_count": summary.get("bull_count"), "bear_count": summary.get("bear_count"), "neutral_count": summary.get("neutral_count"), "opportunity_count": summary.get("opportunity_count"), "symbol_count": plan_data.get("symbol_count"), }, }, } except Exception as e: logger.exception("生成交易计划失败") return {"success": False, "message": f"生成失败: {e!s}"} # ==================== V2 查询接口 ==================== @router.get("/v2/plans") async def list_v2_plans( limit: int = 20, db: AsyncSession = Depends(get_db), ): """获取V2复盘计划列表。""" try: stmt = select(ReviewPlanV2).order_by(ReviewPlanV2.review_date.desc()).limit(limit) result = await db.execute(stmt) plans = result.scalars().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"查询失败: {e!s}"} @router.get("/v2/plan/{plan_id}") async def get_v2_plan( plan_id: int, db: AsyncSession = Depends(get_db), ): """根据ID获取完整V2计划数据。""" try: stmt_plan = select(ReviewPlanV2).where(ReviewPlanV2.id == plan_id) result_plan = await db.execute(stmt_plan) plan_meta = result_plan.scalar_one_or_none() if not plan_meta: return {"success": False, "message": f"计划 {plan_id} 不存在"} stmt_scores = ( select(SymbolScoreV2) .where(SymbolScoreV2.review_date_id == plan_id) .order_by(SymbolScoreV2.composite_score.desc()) ) result_scores = await db.execute(stmt_scores) scores = result_scores.scalars().all() stmt_plans = ( select(TradingPlanV2) .where(TradingPlanV2.review_date_id == plan_id) .order_by(TradingPlanV2.composite_score.desc()) ) result_plans = await db.execute(stmt_plans) trading_plans = result_plans.scalars().all() stmt_sectors = ( select(SectorHeat) .where(SectorHeat.review_date_id == plan_id) .order_by(SectorHeat.avg_score.desc()) ) result_sectors = await db.execute(stmt_sectors) sectors = result_sectors.scalars().all() return { "success": True, "data": { "summary": { "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, "actual_data_date": plan_meta.actual_data_date, "created_at": plan_meta.created_at.isoformat() if plan_meta.created_at else None, }, "scores": [{ "symbol": s.symbol, "name": s.name, "close_price": s.close_price, "composite_score": s.composite_score, "direction": s.direction, "direction_tag": s.direction_tag, "category": s.category, "rank": s.rank, } for s in scores], "plans": [{ "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, "category": p.category, } for p in trading_plans], "sectors": [{ "sector_name": s.sector_name, "avg_score": s.avg_score, "direction": s.direction, "heat_level": s.heat_level, "leader_symbol": s.leader_symbol, "leader_score": s.leader_score, } for s in sectors], }, } except Exception as e: logger.exception("获取V2计划 %d 失败", plan_id) return {"success": False, "message": f"查询失败: {e!s}"} @router.get("/v2/plan/by-date/{date}") async def get_v2_plan_by_date( date: str, db: AsyncSession = Depends(get_db), ): """根据日期获取V2计划数据。""" try: stmt_meta = select(ReviewPlanV2).where(ReviewPlanV2.review_date == date) result_meta = await db.execute(stmt_meta) plan_meta = result_meta.scalar_one_or_none() if not plan_meta: return {"success": False, "message": f"日期 {date} 的计划不存在"} # 复用 get_v2_plan 的逻辑 stmt_scores = ( select(SymbolScoreV2) .where(SymbolScoreV2.review_date_id == plan_meta.id) .order_by(SymbolScoreV2.composite_score.desc()) ) result_scores = await db.execute(stmt_scores) scores = result_scores.scalars().all() stmt_plans = ( select(TradingPlanV2) .where(TradingPlanV2.review_date_id == plan_meta.id) .order_by(TradingPlanV2.composite_score.desc()) ) result_plans = await db.execute(stmt_plans) trading_plans = result_plans.scalars().all() stmt_sectors = ( select(SectorHeat) .where(SectorHeat.review_date_id == plan_meta.id) .order_by(SectorHeat.avg_score.desc()) ) result_sectors = await db.execute(stmt_sectors) sectors = result_sectors.scalars().all() return { "success": True, "data": { "summary": { "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, "actual_data_date": plan_meta.actual_data_date, "created_at": plan_meta.created_at.isoformat() if plan_meta.created_at else None, }, "scores": [{ "symbol": s.symbol, "name": s.name, "close_price": s.close_price, "composite_score": s.composite_score, "direction": s.direction, "direction_tag": s.direction_tag, "category": s.category, "rank": s.rank, } for s in scores], "plans": [{ "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, "category": p.category, } for p in trading_plans], "sectors": [{ "sector_name": s.sector_name, "avg_score": s.avg_score, "direction": s.direction, "heat_level": s.heat_level, "leader_symbol": s.leader_symbol, "leader_score": s.leader_score, } for s in sectors], }, } except Exception as e: logger.exception("获取V2计划 by-date %s 失败", date) return {"success": False, "message": f"查询失败: {e!s}"} @router.delete("/v2/clear") async def clear_v2_data( db: AsyncSession = Depends(get_db), ): """清除所有V2数据。""" try: from sqlalchemy import delete as sa_delete await db.execute(sa_delete(SectorHeat)) await db.execute(sa_delete(TradingPlanV2)) await db.execute(sa_delete(SymbolScoreV2)) await db.execute(sa_delete(ReviewPlanV2)) await db.flush() return {"success": True, "message": "V2复盘数据已清除"} except Exception as e: return {"success": False, "message": f"清除失败: {e!s}"}