from __future__ import annotations import re from datetime import date, datetime, timedelta from typing import Any from backend.bootstrap.config import normalize_date, validate_text from backend.data.providers.ifind_client import IfindError from backend.features.mentor.agent import MentorAgentError, stream_with_mentor MENTOR_DATA_PROFILES = { "emotion": { "kobe92-perspective", "niepanchongsheng-perspective", "chaojiyangjia-perspective", "tuixuechaogu-perspective", "chenxiaoqun-perspective", "zhiyechaoshou-perspective", }, "first_board": { "beijingchaojia-perspective", "chuangshiji-perspective", "xuxiang-perspective", "foshanwuyingjiao-perspective", }, "leader": { "zhaolaoge-perspective", "fangxinxia-perspective", "xiaoe-perspective", "sunge-perspective", "liuyizhonglu-perspective", }, "trend": { "zhangdetao-perspective", "zhangmengzhu-perspective", "zuoshouxinyi-perspective", }, "low_absorption": { "qiaobangzhu-perspective", "asking-perspective", "longfeihu-perspective", "ruihexian-perspective", }, "macro": {"shuipi-perspective"}, } MENTOR_INDEX_UNIVERSE = ( ("000001.SH", "上证指数"), ("399001.SZ", "深证成指"), ("399006.SZ", "创业板指"), ("000016.SH", "上证50"), ("000300.SH", "沪深300"), ("000905.SH", "中证500"), ("000852.SH", "中证1000"), ("932000.CSI", "中证2000"), ) MENTOR_ETF_UNIVERSE = ( ("510050.SH", "上证50ETF"), ("510300.SH", "沪深300ETF"), ("510500.SH", "中证500ETF"), ("512100.SH", "中证1000ETF"), ) class MentorServiceMixin: def mentor_setup(self, trade_date: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) mentors = [ skill.public() for skill in self.mentor_skills.list_skills( include_private=self.membership()["is_admin"] ) ] if not mentors: raise ValueError("游资skills 目录中没有可用的 SKILL.md。") stored_preferences = self.database.list_mentor_preferences(self.current_user_id) preferences = {item["mentor_id"]: item for item in stored_preferences} for default_order, mentor in enumerate(mentors): preference = preferences.get(str(mentor.get("id") or ""), {}) mentor["pinned"] = bool(preference.get("pinned")) mentor["sort_order"] = int(preference.get("sort_order", 10000 + default_order)) mentors.sort( key=lambda item: ( not bool(item.get("pinned")), int(item.get("sort_order") or 0), ) ) for sort_order, mentor in enumerate(mentors): mentor["sort_order"] = sort_order snapshot = self.database.get_snapshot(normalized_date) actual_date = str((snapshot or {}).get("meta", {}).get("trade_date") or normalized_date) return { "trade_date": actual_date, "mentors": mentors, "preferences_configured": bool(stored_preferences), "llm": { "configured": self.llm_configured, "model": self.llm_primary_model if self.llm_configured else "", "fallback_configured": self.llm_fallback_configured, "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", }, } def save_mentor_preferences(self, payload: dict[str, Any]) -> dict[str, Any]: available_ids = [ skill.skill_id for skill in self.mentor_skills.list_skills( include_private=self.membership()["is_admin"] ) ] available = set(available_ids) raw_order = payload.get("order") raw_pinned = payload.get("pinned") if not isinstance(raw_order, list) or not isinstance(raw_pinned, list): raise ValueError("问师排序格式不正确。") ordered_ids: list[str] = [] for raw_id in raw_order: mentor_id = validate_text(raw_id, "问师角色", 100, required=True) if mentor_id not in available: raise ValueError("问师排序中包含不可用的思维模型。") if mentor_id not in ordered_ids: ordered_ids.append(mentor_id) ordered_ids.extend(mentor_id for mentor_id in available_ids if mentor_id not in ordered_ids) pinned_ids = { validate_text(raw_id, "问师角色", 100, required=True) for raw_id in raw_pinned } if not pinned_ids.issubset(available): raise ValueError("问师置顶中包含不可用的思维模型。") self.database.save_mentor_preferences( self.current_user_id, ordered_ids, pinned_ids ) return {"saved": True} def mentor_stream(self, payload: dict[str, Any]): mentor_id = validate_text(payload.get("mentor_id"), "问师角色", 100, required=True) question = validate_text(payload.get("question"), "问题", 2000, required=True) trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) history = self._validate_mentor_history(payload.get("history") or []) skill = self.mentor_skills.get_skill( mentor_id, include_private=self.membership()["is_admin"] ) context = self._build_mentor_context(trade_date, question, skill) def generate(): answer_parts: list[str] = [] follow_ups: list[str] = [] events = self.llm_gateway.stream( "mentor", f"mentor-skill-v2:{skill.skill_id}", lambda profile: stream_with_mentor( skill, context, question, history, profile.api_key, profile.base_url, profile.model, follow_ups=follow_ups, ), (MentorAgentError,), ) for event in events: if event.kind == "delta": chunk = str(event.value or "") answer_parts.append(chunk) yield {"type": "delta", "content": chunk} elif event.kind == "complete": self.database.save_mentor_exchange( self.current_user_id, mentor_id, trade_date, question, "".join(answer_parts).strip(), context["data_trade_date"], ) yield { "type": "meta", "data_trade_date": context["data_trade_date"], "follow_ups": follow_ups or self._mentor_follow_up_fallback(question), "notice": "智能解读已自动切换可用服务。" if event.role == "fallback" else "", } return generate() @staticmethod def _mentor_follow_up_fallback(question: str) -> list[str]: normalized = question.strip() if any(keyword in normalized for keyword in ("风险", "亏损", "回撤", "止损")): return [ "这些风险最早会从哪些信号中暴露?", "哪些变化会让当前风险判断失效?", "如果风险继续扩大,仓位预案应如何调整?", ] if any(keyword in normalized for keyword in ("股票", "个股", "代码", "怎么看")): return [ "这个判断最关键的确认信号是什么?", "哪些变化会让当前结论失效?", "明日盘中应该优先观察哪些数据?", ] return [ "这个判断最关键的确认依据是什么?", "哪些变化会让当前结论失效?", "下一步应该优先观察什么?", ] def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]: mentor_id = validate_text(mentor_id, "问师角色", 100, required=True) trade_date = normalize_date(trade_date) self.mentor_skills.get_skill( mentor_id, include_private=self.membership()["is_admin"] ) return self.database.list_mentor_messages( self.current_user_id, mentor_id, trade_date ) def clear_mentor_messages(self, mentor_id: str, trade_date: str) -> int: mentor_id = validate_text(mentor_id, "问师角色", 100, required=True) trade_date = normalize_date(trade_date) self.mentor_skills.get_skill( mentor_id, include_private=self.membership()["is_admin"] ) return self.database.delete_mentor_messages( self.current_user_id, mentor_id, trade_date ) @staticmethod def _validate_mentor_history(raw_history: Any) -> list[dict[str, str]]: if not isinstance(raw_history, list): raise ValueError("问师对话历史格式不正确。") history = [] total_length = 0 for item in raw_history[-12:]: if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"}: raise ValueError("问师对话历史包含无效消息。") content = str(item.get("content") or "").strip() if not content or len(content) > 5000: raise ValueError("问师对话历史消息为空或过长。") total_length += len(content) if total_length > 24_000: raise ValueError("问师对话历史过长,请清空后重新提问。") history.append({"role": item["role"], "content": content}) return history def _build_mentor_context( self, trade_date: str, question: str, skill: Any | None = None ) -> dict[str, Any]: dashboard = self.get_dashboard(trade_date) data_trade_date = normalize_date( str(dashboard.get("meta", {}).get("trade_date") or trade_date) ) regime = self.screener.detect_regime(data_trade_date) limits = list(dashboard.get("limits") or []) broken = list(dashboard.get("broken") or []) down_limits = list(dashboard.get("down_limits") or []) yesterday_limits = list(dashboard.get("yesterday_limits") or []) all_stocks = limits + broken + down_limits + yesterday_limits matched_rows = [] codes = re.findall(r"(?= 2 and name in question): if not any(item.get("code") == code for item in matched_rows): matched_rows.append(row) for row in matched_rows: code = str(row.get("code") or "") if code and code not in codes: codes.append(code) stock_details = [] for code in codes[:2]: try: detail = self.get_stock_detail(code, data_trade_date) stock_details.append( { "stock": detail.get("stock") or {}, "moneyflow": detail.get("moneyflow") or {}, "recent_prices": (detail.get("prices") or [])[-20:], } ) except Exception as exc: stock_details.append({"code": code, "error": str(exc)}) skill_id = str(getattr(skill, "skill_id", "") or "") profile = next( ( profile_name for profile_name, skill_ids in MENTOR_DATA_PROFILES.items() if skill_id in skill_ids ), "balanced", ) dragon_tiger = None if any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")): try: dragon_payload = self.get_dragon_tiger(data_trade_date) rows = list(dragon_payload.get("rows") or []) matched_dragon = [row for row in rows if str(row.get("code") or "") in codes] leading_dragon = sorted( rows, key=lambda row: abs(float(row.get("net_buy_million") or 0)), reverse=True, )[:12] dragon_tiger = { "summary": dragon_payload.get("summary") or {}, "matched": matched_dragon, "largest_net_flows": leading_dragon, } except Exception as exc: dragon_tiger = {"error": str(exc)} context: dict[str, Any] = { "data_trade_date": data_trade_date, "data_profile": profile, "overview": dashboard.get("overview") or {}, "market_regime": regime, "recent_market_history": self.database.snapshot_summaries(data_trade_date, 10), "question_matched_stocks": matched_rows[:10], "stock_details": stock_details, } ordered_limits = sorted( limits, key=lambda row: ( float(row.get("streak") or 0), float(row.get("amount_billion") or 0), ), reverse=True, ) if profile in {"emotion", "balanced"}: context.update( { "limit_ladder": dashboard.get("ladders") or [], "limit_performance": dashboard.get("limit_performance") or [], "hot_sectors": (dashboard.get("sectors") or [])[:15], "sector_rotation": (dashboard.get("sector_rotation") or [])[:15], "limit_up_stocks": ordered_limits[:30], "broken_stocks": sorted( broken, key=lambda row: float(row.get("amount_billion") or 0), reverse=True, )[:20], "limit_down_stocks": down_limits[:20], "yesterday_limit_performance": sorted( yesterday_limits, key=lambda row: float(row.get("change") or 0), reverse=True, )[:20], } ) elif profile == "first_board": context.update( { "first_board_environment": { "seal_rate": (dashboard.get("overview") or {}).get("seal_rate"), "broken_count": len(broken), "first_boards": [row for row in ordered_limits if int(row.get("streak") or 1) == 1][:35], "broken_stocks": sorted( broken, key=lambda row: float(row.get("amount_billion") or 0), reverse=True, )[:30], }, "hot_sectors": (dashboard.get("sectors") or [])[:12], } ) elif profile == "leader": context.update( { "limit_ladder": dashboard.get("ladders") or [], "multi_board_leaders": [ row for row in ordered_limits if int(row.get("streak") or 0) >= 2 ][:25], "hot_sectors": (dashboard.get("sectors") or [])[:12], "sector_rotation": (dashboard.get("sector_rotation") or [])[:12], } ) try: popularity = self.popularity(data_trade_date) context["popularity_core"] = { "consensus": [ row for row in (popularity.get("combined") or []) if row.get("dual_source") ][:10], "ths": (popularity.get("ths") or [])[:10], "eastmoney": (popularity.get("dc") or [])[:10], } except Exception: context["popularity_core"] = {"unavailable": True} elif profile == "trend": context.update( { "index_momentum": self._mentor_market_matrix( data_trade_date, MENTOR_INDEX_UNIVERSE ), "sector_rotation": (dashboard.get("sector_rotation") or [])[:20], "hot_sectors": (dashboard.get("sectors") or [])[:20], "market_breadth": { key: (dashboard.get("overview") or {}).get(key) for key in ("up_count", "down_count", "flat_count", "amount_billion") }, } ) elif profile == "low_absorption": context.update( { "yesterday_limit_performance": sorted( yesterday_limits, key=lambda row: float(row.get("change") or 0), reverse=True, )[:35], "broken_stocks": broken[:20], "hot_sectors": (dashboard.get("sectors") or [])[:12], } ) elif profile == "macro": context.update( { "broad_indexes": self._mentor_market_matrix( data_trade_date, MENTOR_INDEX_UNIVERSE ), "core_etfs": self._mentor_market_matrix( data_trade_date, MENTOR_ETF_UNIVERSE ), "market_style": { "amount_billion": (dashboard.get("overview") or {}).get("amount_billion"), "breadth": { "up": (dashboard.get("overview") or {}).get("up_count"), "down": (dashboard.get("overview") or {}).get("down_count"), }, "top_sectors": (dashboard.get("sectors") or [])[:15], }, "unavailable_data": [ "政策原文与隔夜资讯尚未接入", "汇率、利率和商品宏观序列当前不可用", ], } ) if dragon_tiger is not None: context["dragon_tiger"] = dragon_tiger return context def _mentor_market_matrix( self, trade_date: str, universe: tuple[tuple[str, str], ...] ) -> list[dict[str, Any]]: ifind = getattr(self, "ifind", None) if not ifind or not ifind.configured: return [] end = datetime.strptime(trade_date, "%Y%m%d") start = (end - timedelta(days=45)).strftime("%Y%m%d") names = {code: name for code, name in universe} try: rows = ifind.history( list(names), ["close", "volume", "amount"], start, trade_date, cache_ttl=600 ) except IfindError: return [] grouped: dict[str, list[dict[str, Any]]] = {} for row in rows: code = str(row.get("thscode") or "").upper() if code in names: grouped.setdefault(code, []).append(row) result = [] for code, name in universe: series = sorted(grouped.get(code, []), key=lambda row: str(row.get("time") or "")) closes = [] for row in series: try: close = float(row.get("close") or 0) except (TypeError, ValueError): continue if close > 0: closes.append(close) if not closes: continue def period_return(days: int) -> float | None: if len(closes) <= days or closes[-days - 1] <= 0: return None return round((closes[-1] / closes[-days - 1] - 1) * 100, 2) previous = closes[-2] if len(closes) > 1 else 0 result.append( { "code": code, "name": name, "close": round(closes[-1], 3), "change": round((closes[-1] / previous - 1) * 100, 2) if previous else None, "return_5d": period_return(5), "return_10d": period_return(10), "return_20d": period_return(20), "latest_amount": series[-1].get("amount") if series else None, } ) return result