Files
xiaobaifupan/app/backend/features/mentor/service.py
T

457 lines
19 KiB
Python

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] = []
events = self.llm_gateway.stream(
"mentor",
f"mentor-skill-v1:{skill.skill_id}",
lambda profile: stream_with_mentor(
skill,
context,
question,
history,
profile.api_key,
profile.base_url,
profile.model,
),
(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"],
"notice": "智能解读已自动切换可用服务。"
if event.role == "fallback"
else "",
}
return generate()
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"(?<!\d)\d{6}(?!\d)", question)[:3]
for row in all_stocks:
code = str(row.get("code") or "")
name = str(row.get("name") or "")
if code in codes or (len(name) >= 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