"""Opportunity card ranking agent.""" from __future__ import annotations from typing import Any from app.research.feedback_agent import apply_feedback_to_card from app.research.industry_chain_agent import infer_chain_node def build_opportunity_cards( recommendations: list[Any], stock_notes: list[dict], risk_alerts: list[dict], feedback: dict | None = None, ) -> list[dict]: note_map = {item["ts_code"]: item for item in stock_notes} rejected = {item["ts_code"] for item in risk_alerts if item.get("reject") and item.get("ts_code")} risk_by_code: dict[str, list[dict]] = {} global_risks: list[dict] = [] for risk in risk_alerts: if risk.get("ts_code"): risk_by_code.setdefault(risk["ts_code"], []).append(risk) else: global_risks.append(risk) cards: list[dict] = [] for rec in recommendations: ts_code = getattr(rec, "ts_code", "") if ts_code in rejected: continue note = note_map.get(ts_code, {}) action_plan = getattr(rec, "action_plan", "观察") or "观察" if action_plan == "可操作": opportunity_type = "可操作" elif action_plan == "重点关注": opportunity_type = "等确认" else: hint = ((getattr(rec, "decision_trace", {}) or {}).get("position_adjustment") or {}).get("hint", "") opportunity_type = "等回踩" if hint == "wait_pullback" else "仅观察" theme = getattr(rec, "sector", "") or note.get("theme") or "未归类" alpha_profile = _build_alpha_profile(rec, note, risk_by_code.get(ts_code, []) + global_risks) card = { "ts_code": ts_code, "name": getattr(rec, "name", ""), "theme": theme, "chain_node": note.get("chain_node") or infer_chain_node(theme, getattr(rec, "name", ""), theme), "stock_role": note.get("stock_role", "待归类"), "opportunity_type": opportunity_type, "score": round(float(getattr(rec, "score", 0) or 0), 1), "logic_score": note.get("logic_score", 0), "action_plan": action_plan, "trigger": getattr(rec, "trigger_condition", "") or getattr(rec, "entry_timing", ""), "invalid_condition": note.get("invalid_condition") or getattr(rec, "invalidation_condition", "") or getattr(rec, "risk_note", ""), "logic_summary": note.get("logic_summary", ""), **alpha_profile, } cards.append(apply_feedback_to_card(card, rec, risk_by_code.get(ts_code, []) + global_risks, feedback)) cards.sort( key=lambda item: ( {"可操作": 3, "等确认": 2, "等回踩": 1, "仅观察": 0}.get(item["opportunity_type"], 0), item.get("alpha_score", 0), item.get("ambush_score", 0), item.get("adjusted_score", item["score"]), item["logic_score"], ), reverse=True, ) return cards[:20] def _build_alpha_profile(rec: Any, note: dict, risks: list[dict]) -> dict: trace = getattr(rec, "decision_trace", {}) or {} position = trace.get("position_adjustment") or {} hint = position.get("hint", "") action_plan = getattr(rec, "action_plan", "观察") or "观察" score = float(getattr(rec, "score", 0) or 0) market_score = float(getattr(rec, "market_temp_score", 0) or 0) sector_score = float(getattr(rec, "sector_score", 0) or 0) capital_score = float(getattr(rec, "capital_score", 0) or 0) technical_score = float(getattr(rec, "technical_score", 0) or 0) valuation_score = float(getattr(rec, "valuation_score", 0) or 0) position_score = float(getattr(rec, "position_score", 0) or 0) logic_score = float(note.get("logic_score", 0) or 0) risk_penalty = min(sum(18 if item.get("reject") else 7 for item in risks), 35) beta_dependency_score = _clamp( 20 + market_score * 0.55 + max(0, sector_score - capital_score) * 0.2 - min(capital_score, 20) * 0.15 ) alpha_score = _clamp( score * 0.25 + sector_score * 0.18 + capital_score * 0.22 + technical_score * 0.16 + logic_score * 0.14 + valuation_score * 0.08 - beta_dependency_score * 0.12 - risk_penalty ) ambush_score = _clamp( position_score * 0.26 + valuation_score * 0.22 + capital_score * 0.18 + technical_score * 0.16 + (12 if hint in {"wait_confirm", "actionable_pullback", "wait_pullback"} else 0) + (8 if action_plan in {"重点关注", "观察"} else 0) - risk_penalty * 0.7 ) expectation_gap_score = _clamp( logic_score * 0.28 + sector_score * 0.2 + capital_score * 0.2 + valuation_score * 0.16 + (10 if note.get("generated_by") == "llm" else 4) - market_score * 0.1 - risk_penalty * 0.6 ) risk_gate = _risk_gate(risks) alpha_type = _alpha_type(action_plan, hint, ambush_score, expectation_gap_score, beta_dependency_score) beta_dependency = _beta_label(beta_dependency_score) setup_quality = _setup_quality(alpha_score, ambush_score, risk_gate) return { "alpha_type": alpha_type, "alpha_score": round(alpha_score, 1), "beta_dependency": beta_dependency, "beta_dependency_score": round(beta_dependency_score, 1), "ambush_score": round(ambush_score, 1), "expectation_gap_score": round(expectation_gap_score, 1), "risk_gate": risk_gate, "setup_quality": setup_quality, "alpha_reason": _alpha_reason(alpha_type, beta_dependency, ambush_score, expectation_gap_score, risk_gate), } def _alpha_type(action_plan: str, hint: str, ambush_score: float, expectation_gap_score: float, beta_dependency_score: float) -> str: if ambush_score >= 70 and action_plan != "可操作": return "低位埋伏" if hint == "wait_pullback": return "强势等回踩" if hint == "wait_confirm" or action_plan == "重点关注": return "等确认" if expectation_gap_score >= 72 and beta_dependency_score <= 65: return "预期差机会" if action_plan == "可操作": return "趋势确认" return "观察线索" def _risk_gate(risks: list[dict]) -> str: if any(item.get("reject") for item in risks): return "否决" if risks: return "预警" return "通过" def _beta_label(score: float) -> str: if score >= 70: return "高" if score >= 45: return "中" return "低" def _setup_quality(alpha_score: float, ambush_score: float, risk_gate: str) -> str: if risk_gate == "否决": return "不参与" if alpha_score >= 78 and ambush_score >= 65: return "优先研究" if alpha_score >= 65 or ambush_score >= 70: return "可跟踪" return "仅观察" def _alpha_reason(alpha_type: str, beta_dependency: str, ambush_score: float, expectation_gap_score: float, risk_gate: str) -> str: if risk_gate == "否决": return "风险门槛未通过,先排除。" parts = [f"{alpha_type},Beta依赖{beta_dependency}"] if ambush_score >= 70: parts.append("位置更适合提前跟踪") if expectation_gap_score >= 70: parts.append("存在预期差") if risk_gate == "预警": parts.append("但有风险预警") return ";".join(parts) + "。" def _clamp(value: float, low: float = 0, high: float = 100) -> float: return max(low, min(high, value))