181 lines
6.6 KiB
Python
181 lines
6.6 KiB
Python
"""推荐列表 API"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import traceback
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends
|
|
|
|
from app.engine.recommender import (
|
|
refresh_recommendations,
|
|
get_latest_recommendations,
|
|
get_latest_market_anomalies,
|
|
get_recommendation_history,
|
|
get_performance_stats,
|
|
)
|
|
from app.config import is_trading_hours
|
|
from app.core.deps import get_current_admin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/recommendations", tags=["recommendations"])
|
|
|
|
|
|
@router.get("/latest")
|
|
async def get_latest():
|
|
"""获取最新推荐列表"""
|
|
result = await get_latest_recommendations()
|
|
anomalies = await get_latest_market_anomalies()
|
|
|
|
mt = result.get("market_temp")
|
|
return {
|
|
"market_temperature": ({
|
|
"trade_date": mt.trade_date if mt else "",
|
|
"temperature": mt.temperature if mt else 0,
|
|
"up_count": mt.up_count if mt else 0,
|
|
"down_count": mt.down_count if mt else 0,
|
|
"limit_up_count": mt.limit_up_count if mt else 0,
|
|
"limit_down_count": mt.limit_down_count if mt else 0,
|
|
"max_streak": mt.max_streak if mt else 0,
|
|
"broken_rate": mt.broken_rate if mt else 0,
|
|
"index_above_ma20": mt.index_above_ma20 if mt else False,
|
|
} if mt else None),
|
|
"recommendations": [
|
|
{
|
|
"ts_code": r.ts_code,
|
|
"name": r.name,
|
|
"sector": r.sector,
|
|
"score": r.score,
|
|
"level": r.level,
|
|
"signal": r.signal,
|
|
"market_temp_score": r.market_temp_score,
|
|
"sector_score": r.sector_score,
|
|
"capital_score": r.capital_score,
|
|
"technical_score": r.technical_score,
|
|
"supply_demand_score": r.supply_demand_score,
|
|
"price_action_score": r.price_action_score,
|
|
"position_score": r.position_score,
|
|
"valuation_score": r.valuation_score,
|
|
"entry_price": r.entry_price,
|
|
"target_price": r.target_price,
|
|
"stop_loss": r.stop_loss,
|
|
"reasons": r.reasons,
|
|
"risk_note": r.risk_note,
|
|
"llm_analysis": r.llm_analysis,
|
|
"entry_timing": r.entry_timing,
|
|
"action_plan": r.action_plan,
|
|
"trigger_condition": r.trigger_condition,
|
|
"invalidation_condition": r.invalidation_condition,
|
|
"suggested_position_pct": r.suggested_position_pct,
|
|
"review_after_days": r.review_after_days,
|
|
"lifecycle_status": r.lifecycle_status,
|
|
"data_freshness": r.data_freshness,
|
|
"llm_score": r.llm_score,
|
|
"recall_tags": r.recall_tags,
|
|
"prefilter_decision": r.prefilter_decision,
|
|
"prefilter_reason": r.prefilter_reason,
|
|
"focus_points": r.focus_points,
|
|
"decision_trace": r.decision_trace,
|
|
"strategy": r.strategy,
|
|
"entry_signal_type": r.entry_signal_type,
|
|
"scan_session": r.scan_session,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
}
|
|
for r in result.get("recommendations", [])
|
|
],
|
|
"market_anomalies": anomalies,
|
|
"scan_mode": result.get("scan_mode", "unknown"),
|
|
"strategy_profile": result.get("strategy_profile"),
|
|
"latest_scan": result.get("latest_scan"),
|
|
}
|
|
|
|
|
|
@router.post("/refresh")
|
|
async def refresh(scan_session: str = "manual", _admin: dict = Depends(get_current_admin)):
|
|
"""手动触发一次全量筛选(后台执行,立即返回)"""
|
|
from app.engine.recommender import _scan_running, _scan_lock
|
|
|
|
if _scan_running:
|
|
return {
|
|
"status": "already_running",
|
|
"message": "扫描正在执行中,请稍候",
|
|
"is_trading": is_trading_hours(),
|
|
}
|
|
|
|
# 在后台执行扫描,立即返回响应
|
|
asyncio.create_task(_run_scan_background(scan_session))
|
|
|
|
return {
|
|
"status": "scanning",
|
|
"message": "扫描已启动,完成后自动刷新",
|
|
"is_trading": is_trading_hours(),
|
|
}
|
|
|
|
|
|
async def _run_scan_background(scan_session: str):
|
|
"""后台执行扫描并推送结果"""
|
|
from app.engine.recommender import refresh_recommendations
|
|
from app.api.websocket import broadcast_update
|
|
|
|
try:
|
|
result = await refresh_recommendations(scan_session=scan_session)
|
|
rec_count = len(result.get("recommendations", []))
|
|
mt = result.get("market_temp")
|
|
|
|
# 通过 WebSocket 推送扫描完成通知
|
|
await broadcast_update({
|
|
"type": "scan_update",
|
|
"session": scan_session,
|
|
"count": rec_count,
|
|
"temperature": mt.temperature if mt else 0,
|
|
"scan_mode": result.get("scan_mode", "unknown"),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"后台扫描失败: {e}")
|
|
from app.db.error_logger import log_error
|
|
await log_error("recommender_api", f"后台扫描失败: {e}", detail=traceback.format_exc())
|
|
await broadcast_update({
|
|
"type": "scan_error",
|
|
"session": scan_session,
|
|
"message": str(e),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
|
|
|
|
@router.post("/update-tracking")
|
|
async def update_tracking(_admin: dict = Depends(get_current_admin)):
|
|
"""独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)"""
|
|
from app.engine.recommender import _update_tracking
|
|
await _update_tracking()
|
|
stats = await get_performance_stats()
|
|
return {
|
|
"status": "ok",
|
|
"tracked": stats.get("tracked", 0),
|
|
"win_rate": stats.get("win_rate", 0),
|
|
"avg_return": stats.get("avg_return", 0),
|
|
}
|
|
|
|
|
|
@router.get("/status")
|
|
async def get_scan_status():
|
|
"""获取当前扫描状态信息。只根据本地时间判断,不访问外部数据源。"""
|
|
prefer_realtime = is_trading_hours()
|
|
return {
|
|
"is_trading": is_trading_hours(),
|
|
"scan_mode": "realtime_today" if prefer_realtime else "post_market",
|
|
"description": "交易时段,扫描任务会使用实时源" if prefer_realtime else "非交易时段,展示最近扫描结论",
|
|
}
|
|
|
|
|
|
@router.get("/history")
|
|
async def get_history(days: int = 7):
|
|
"""获取历史推荐(按日期分组)"""
|
|
return await get_recommendation_history(days)
|
|
|
|
|
|
@router.get("/performance")
|
|
async def performance():
|
|
"""获取推荐胜率统计"""
|
|
return await get_performance_stats()
|