You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
buffer_platform/buffer_platform_v2/app/api/review.py

807 lines
28 KiB

"""复盘计划 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}"}