457 lines
19 KiB
Python
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
|