"""Build and persist daily AI research reports.""" from __future__ import annotations import json from datetime import datetime from typing import Any from sqlalchemy import text from app.db.database import get_db from app.db import tables from app.research.catalyst_agent import build_catalyst_summary from app.research.feedback_agent import build_ranking_feedback from app.research.market_agent import build_market_view from app.research.ranking_agent import build_opportunity_cards from app.research.risk_agent import build_risk_alerts from app.research.sector_agent import build_theme_views, build_theme_views_async from app.research.stock_research_agent import build_stock_research_notes, build_stock_research_notes_sync def _latest_scan_from_result(result: dict, scan_session: str) -> dict: for item in result.get("scan_logs", []) or []: if item.get("stage") == "final_filter": return item return { "scan_session": scan_session, "scan_mode": result.get("scan_mode", ""), "status": "empty" if not result.get("recommendations") else "ok", "summary": "", "elimination_reasons": {}, "created_at": datetime.now().isoformat(), } def build_research_report(result: dict, scan_session: str) -> dict: """Build a report with deterministic stock notes. This remains available for fast API fallbacks and tests. Normal scans use build_research_report_async so the stock research notes can call the LLM. """ return _assemble_research_report( result, scan_session, build_stock_research_notes_sync, ) async def build_research_report_async(result: dict, scan_session: str) -> dict: return await _assemble_research_report_async(result, scan_session) def _common_inputs(result: dict, scan_session: str) -> tuple[Any, dict, list, list, dict, dict, list, dict, list]: market_temp = result.get("market_temp") strategy_profile = result.get("strategy_profile") or {} sectors = result.get("hot_sectors", []) or [] recommendations = result.get("recommendations", []) or [] latest_scan = result.get("latest_scan") or _latest_scan_from_result(result, scan_session) theme_views = build_theme_views(sectors) _calibrate_market_from_themes(market_temp, theme_views) market_view = build_market_view(market_temp, strategy_profile) catalyst = build_catalyst_summary(theme_views) risks = build_risk_alerts(recommendations, market_view, latest_scan) return market_temp, strategy_profile, sectors, recommendations, latest_scan, market_view, theme_views, catalyst, risks def _assemble_research_report(result: dict, scan_session: str, notes_builder) -> dict: market_temp, _, _, recommendations, latest_scan, market_view, theme_views, catalyst, risks = _common_inputs(result, scan_session) stock_notes = notes_builder(recommendations, theme_views) return _finalize_research_report(result, scan_session, market_temp, recommendations, latest_scan, market_view, theme_views, catalyst, risks, stock_notes, None, None) async def _assemble_research_report_async(result: dict, scan_session: str) -> dict: market_temp = result.get("market_temp") strategy_profile = result.get("strategy_profile") or {} sectors = result.get("hot_sectors", []) or [] recommendations = result.get("recommendations", []) or [] latest_scan = result.get("latest_scan") or _latest_scan_from_result(result, scan_session) theme_views = await build_theme_views_async(sectors) _calibrate_market_from_themes(market_temp, theme_views) market_view = build_market_view(market_temp, strategy_profile) catalyst = build_catalyst_summary(theme_views) risks = build_risk_alerts(recommendations, market_view, latest_scan) stock_notes = await build_stock_research_notes(recommendations, theme_views, risks) feedback = await build_ranking_feedback(days=60) data_quality = await _build_data_quality_report(market_view) return _finalize_research_report(result, scan_session, market_temp, recommendations, latest_scan, market_view, theme_views, catalyst, risks, stock_notes, feedback, data_quality) def _finalize_research_report( result: dict, scan_session: str, market_temp: Any, recommendations: list, latest_scan: dict, market_view: dict, theme_views: list[dict], catalyst: dict, risks: list[dict], stock_notes: list[dict], feedback: dict | None, data_quality: dict | None, ) -> dict: opportunities = build_opportunity_cards(recommendations, stock_notes, risks, feedback) trade_date = getattr(market_temp, "trade_date", "") or datetime.now().strftime("%Y%m%d") if opportunities: no_trade_reason = {"has_scan": True, "reason": "", "blocked_by": []} else: elimination = latest_scan.get("elimination_reasons") or {} if elimination: reason = ";".join(f"{k} {v}只" for k, v in list(elimination.items())[:3]) elif recommendations: reason = "候选存在,但机会卡被风险或动作分层过滤。" else: reason = "本轮扫描没有形成满足条件的交易候选。" no_trade_reason = {"has_scan": True, "reason": reason, "blocked_by": list(elimination.keys())[:5]} top_theme_names = [item["theme"] for item in theme_views[:3]] report = { "trade_date": trade_date, "scan_session": scan_session, "scan_mode": result.get("scan_mode", ""), "scanned_at": datetime.now().isoformat(), "market_view": market_view, "theme_views": theme_views, "industry_chain_map": [ { "theme": item["theme"], "chain_nodes": item["chain_nodes"], "chain_items": item.get("chain_items", []), "leader_stocks": item.get("leader_stocks", []), } for item in theme_views ], "catalyst": catalyst, "stock_research_notes": stock_notes, "opportunity_cards": opportunities, "risk_alerts": risks, "ranking_feedback": feedback or {}, "risk_summary": { "reject_count": sum(1 for item in risks if item.get("reject")), "warning_count": sum(1 for item in risks if not item.get("reject")), "types": sorted({item.get("risk_type", "") for item in risks if item.get("risk_type")}), }, "data_quality": data_quality or _fallback_data_quality(market_view), "no_trade_reason": no_trade_reason, "summary": { "market": market_view["summary"], "theme": f"当前主线关注 {'、'.join(top_theme_names)}。" if top_theme_names else "暂未形成清晰主线。", "opportunity_count": len(opportunities), "risk_count": len(risks), }, } return report async def _build_data_quality_report(market_view: dict) -> dict: watched_sources = ("eastmoney", "tencent", "market_breadth") since = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) issues = [] async with get_db() as db: result = await db.execute( text( "SELECT source, level, message, created_at FROM error_logs " "WHERE created_at >= :since AND (" "source LIKE '%eastmoney%' OR source LIKE '%tencent%' OR source = 'market_breadth'" ") ORDER BY created_at DESC, id DESC LIMIT 20" ), {"since": since.strftime("%Y-%m-%d %H:%M:%S")}, ) for row in result.fetchall(): item = dict(row._mapping) issues.append({ "source": item.get("source", ""), "level": item.get("level", ""), "message": item.get("message", ""), "created_at": str(item.get("created_at") or ""), }) warnings = [] market_status = market_view.get("data_status") or "fresh" if market_status == "estimated": warnings.append("全市场涨跌停使用实时行情阈值估算,非涨跌停池精确口径。") if issues: warnings.append("今日存在行情源失败记录,盘中结论已降级处理。") status = "degraded" if warnings or issues else "ok" return { "status": status, "market_data_status": market_status, "market_source": market_view.get("source", ""), "limit_counts_reliable": bool(market_view.get("limit_counts_reliable", False)), "warnings": warnings, "issues": issues[:8], } def _fallback_data_quality(market_view: dict) -> dict: market_status = market_view.get("data_status") or "fresh" warnings = [] if market_status == "estimated": warnings.append("全市场涨跌停使用实时行情阈值估算。") return { "status": "degraded" if warnings else "ok", "market_data_status": market_status, "market_source": market_view.get("source", ""), "limit_counts_reliable": bool(market_view.get("limit_counts_reliable", False)), "warnings": warnings, "issues": [], } def _calibrate_market_from_themes(market_temp: Any, theme_views: list[dict]) -> None: if not market_temp or not theme_views: return theme_limit_up = sum(max(int(item.get("limit_up_count") or 0), 0) for item in theme_views) if theme_limit_up <= int(getattr(market_temp, "limit_up_count", 0) or 0): return original_limit = int(getattr(market_temp, "limit_up_count", 0) or 0) original_temp = float(getattr(market_temp, "temperature", 0) or 0) market_temp.limit_up_count = theme_limit_up if original_limit == 0: market_temp.temperature = round(min(original_temp + min(theme_limit_up / 5, 8), 100), 1) if not getattr(market_temp, "limit_counts_reliable", False): market_temp.data_status = "estimated" detail = getattr(market_temp, "source_detail", "") or "" market_temp.source_detail = f"{detail};theme_limit_lower_bound" async def save_research_report(report: dict) -> None: trade_date = str(report.get("trade_date") or "") scan_session = str(report.get("scan_session") or "manual") now = datetime.now() async with get_db() as db: await db.execute(text("DELETE FROM research_reports WHERE scan_session = :session"), {"session": scan_session}) await db.execute(text("DELETE FROM theme_maps WHERE scan_session = :session"), {"session": scan_session}) await db.execute(text("DELETE FROM theme_chain_nodes WHERE scan_session = :session"), {"session": scan_session}) await db.execute(text("DELETE FROM stock_research_notes WHERE scan_session = :session"), {"session": scan_session}) await db.execute(text("DELETE FROM risk_events WHERE scan_session = :session"), {"session": scan_session}) await db.execute(text("DELETE FROM opportunity_cards WHERE scan_session = :session"), {"session": scan_session}) await db.execute(tables.research_reports_table.insert().values( scan_session=scan_session, trade_date=trade_date, market_summary=report.get("summary", {}).get("market", ""), theme_summary=report.get("summary", {}).get("theme", ""), no_trade_reason=json.dumps(report.get("no_trade_reason", {}), ensure_ascii=False), report_json=json.dumps(report, ensure_ascii=False, default=str), created_at=now, )) for theme in report.get("theme_views", []): await db.execute(tables.theme_maps_table.insert().values( scan_session=scan_session, trade_date=trade_date, theme_name=theme.get("theme", ""), stage=theme.get("stage", ""), heat_score=theme.get("heat_score", 0), logic_summary=theme.get("logic", ""), lifecycle_status=theme.get("lifecycle_status", ""), created_at=now, )) for node in theme.get("chain_nodes", []) or ["未归类"]: chain_item = _chain_item_for_node(theme, node) await db.execute(tables.theme_chain_nodes_table.insert().values( scan_session=scan_session, trade_date=trade_date, theme_name=theme.get("theme", ""), chain_node=node, related_stocks=json.dumps(chain_item.get("related_stocks", []), ensure_ascii=False, default=str), leader_stocks=json.dumps(chain_item.get("leader_stocks", []) or theme.get("leader_stocks", []), ensure_ascii=False, default=str), created_at=now, )) for note in report.get("stock_research_notes", []): await db.execute(tables.stock_research_notes_table.insert().values( scan_session=scan_session, trade_date=trade_date, ts_code=note.get("ts_code", ""), name=note.get("name", ""), theme=note.get("theme", "未归类"), chain_node=note.get("chain_node", "未归类"), logic_score=note.get("logic_score", 0), logic_summary=note.get("logic_summary", ""), evidence_json=json.dumps(note.get("evidence", []), ensure_ascii=False, default=str), uncertainty=note.get("uncertainty", ""), stock_role=note.get("stock_role", "待归类"), disagreement=note.get("disagreement", ""), invalid_condition=note.get("invalid_condition", ""), generated_by=note.get("generated_by", "rules"), created_at=now, )) for risk in report.get("risk_alerts", []): await db.execute(tables.risk_events_table.insert().values( scan_session=scan_session, trade_date=trade_date, ts_code=risk.get("ts_code", ""), risk_type=risk.get("risk_type", ""), severity=risk.get("severity", "warning"), reject=bool(risk.get("reject")), reason=risk.get("reason", ""), source=risk.get("source", "research_agent"), created_at=now, )) for card in report.get("opportunity_cards", []): await db.execute(tables.opportunity_cards_table.insert().values( scan_session=scan_session, trade_date=trade_date, ts_code=card.get("ts_code", ""), name=card.get("name", ""), theme=card.get("theme", ""), chain_node=card.get("chain_node", "未归类"), stock_role=card.get("stock_role", "待归类"), opportunity_type=card.get("opportunity_type", "观察"), score=card.get("adjusted_score", card.get("score", 0)), alpha_type=card.get("alpha_type", "观察线索"), alpha_score=card.get("alpha_score", 0), beta_dependency=card.get("beta_dependency", "中"), beta_dependency_score=card.get("beta_dependency_score", 0), ambush_score=card.get("ambush_score", 0), expectation_gap_score=card.get("expectation_gap_score", 0), risk_gate=card.get("risk_gate", "通过"), setup_quality=card.get("setup_quality", "仅观察"), alpha_reason=card.get("alpha_reason", ""), action_plan=card.get("action_plan", "观察"), trigger=card.get("trigger", ""), invalid_condition=card.get("invalid_condition", ""), created_at=now, )) await db.commit() def _chain_item_for_node(theme: dict, node: str) -> dict: for item in theme.get("chain_items", []) or []: if item.get("chain_node") == node: return item return {} async def load_latest_research_report() -> dict | None: async with get_db() as db: result = await db.execute(text("SELECT report_json FROM research_reports ORDER BY created_at DESC, id DESC LIMIT 1")) row = result.fetchone() if not row: return None try: return json.loads(row._mapping["report_json"] or "{}") except Exception: return None