Files
group_fqcd_jr/app/service/agent/advisor_agent.py
T

500 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""场内基金模拟交易的投资顾问 Agent。"""
import re
from decimal import Decimal
from typing import Any, cast
from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
from app.service.agent.base import BaseAgent
class AdvisorAgent(BaseAgent):
"""提供只读行情、账户分析和适当性辅助,不执行任何交易。"""
definition = AgentDefinition(
agent_type="advisor",
version="1.5.0",
allowed_tools=(
"query_fund_quote",
"query_financial_data",
"check_suitability",
"query_investment_goal",
"query_customer_profile",
"generate_asset_allocation",
"recommend_products",
"analyze_portfolio",
"simulate_portfolio_rebalance",
),
allowed_roles=("customer", "advisor", "operator", "admin"),
allowed_portals=("api",),
supported_intents=(
"fund_quote",
"financial_query",
"suitability",
"investment_goal",
"product_recommend",
"portfolio_analysis",
"asset_allocation",
"comparison",
"general",
),
intent_descriptions={
"fund_quote": "查询指定六码场内基金的行情或净值",
"financial_query": "查询已授权的账户、交易、持仓或产品数据",
"suitability": "核验客户风险等级与产品风险等级是否适当",
"investment_goal": "采集或查看收益目标、回撤、流动性和投资期限",
"product_recommend": "用户要求推荐适合的场内基金或产品",
"portfolio_analysis": "用户要求分析持仓、集中度或行业分布",
"asset_allocation": "用户要求给出资产配置比例或资产建议",
"comparison": "用户要求比较多个产品、客户持仓或共同持仓",
"general": "其他场内基金模拟交易相关咨询",
},
)
_FUND_CODE = re.compile(r"(?<!\d)(\d{6})(?!\d)")
_TRADE_REQUEST = re.compile(
r"(?:请|帮我|替我|现在|立即|马上|直接).{0,8}(?:买入|卖出|下单|申购|赎回|撤单)"
)
_FINANCIAL_QUERY = (
"持仓", "交易记录", "资金流水", "现金余额", "账户余额", "客户画像", "风险测评",
"产品资料", "费率", "历史净值", "成交记录", "委托记录", "订单",
)
_GOAL_TERMS = ("投资目标", "收益目标", "最大回撤", "流动性", "投资期限", "目标书")
_PRODUCT_RECOMMEND_TERMS = ("推荐", "有什么适合", "给客户推荐")
_PORTFOLIO_ANALYSIS_TERMS = ("持仓分析", "集中", "分布在哪些行业", "风险预警")
_ASSET_ALLOCATION_TERMS = ("配置比例", "怎么分配", "资产建议")
_COMPARISON_TERMS = ("共同持仓", "有什么不同", "对比")
_ADVISOR_REVIEW_TERMS = ("完整投顾", "投顾全景", "投资复盘", "完整建议")
_DISCLAIMER = "基金投资有风险,过往业绩不预示未来表现。本回复仅供分析参考,不构成交易指令。"
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
message = request.message.strip()
if self._TRADE_REQUEST.search(message):
return CoreResult(
text=("我不能代您执行申购、赎回、买卖、下单或确认成交。"
"如需交易,请通过受监管的交易渠道完成确认。\n\n" + self._DISCLAIMER)
)
classified_intent = self._classified_intent.intent if self._classified_intent else None
if "调仓模拟" in message or "配置偏离" in message:
return await self._portfolio_rebalance(context)
if any(term in message for term in self._ADVISOR_REVIEW_TERMS):
return await self._advisor_review(context)
if classified_intent == "asset_allocation" or any(
term in message for term in self._ASSET_ALLOCATION_TERMS
):
return await self._asset_allocation(context)
if classified_intent == "portfolio_analysis" or any(
term in message for term in self._PORTFOLIO_ANALYSIS_TERMS
):
return await self._portfolio_analysis(message, context)
if classified_intent == "product_recommend" or any(
term in message for term in self._PRODUCT_RECOMMEND_TERMS
):
return await self._product_recommendation(context)
if classified_intent == "comparison" or any(
term in message for term in self._COMPARISON_TERMS
):
return self._comparison_unavailable()
if any(term in message for term in self._GOAL_TERMS):
return await self._investment_goal(context)
risks = self._risk_levels(message)
if risks is not None:
return await self._check_suitability(risks, context)
codes = tuple(dict.fromkeys(self._FUND_CODE.findall(message)))
if codes:
return await self._quote_funds(codes, context)
if any(term in message for term in self._FINANCIAL_QUERY):
return await self._query_financial_data(message, context)
return CoreResult(
text=("我可以协助解读场内基金行情、已授权范围内的持仓与交易数据,并进行适当性辅助判断。\n\n"
+ self._DISCLAIMER)
)
async def _advisor_review(self, context: RequestContext) -> CoreResult:
required = {
"asset-allocation:generate:self",
"customer-profile:read:self",
"investment-goal:read:self",
"portfolio-analysis:read:self",
"product-recommendation:read:self",
"suitability:read",
}
if not required.issubset(context.permissions):
return CoreResult(
text=(
"生成投顾全景复盘前,需要完成风险测评、确认投资目标并具备只读分析权限。\n\n"
+ self._DISCLAIMER
)
)
allocation = cast(dict[str, Any], await self.call_tool(
"generate_asset_allocation", {}, intent="asset_allocation", context=context
))
if allocation.get("status") != "ready":
return CoreResult(text="投资目标或风险测评尚未满足分析条件。\n\n" + self._DISCLAIMER)
portfolio = cast(dict[str, Any], await self.call_tool(
"analyze_portfolio", {}, intent="portfolio_analysis", context=context
))
rebalance = cast(dict[str, Any], await self.call_tool(
"simulate_portfolio_rebalance", {}, intent="asset_allocation", context=context
))
recommendation = cast(dict[str, Any], await self.call_tool(
"recommend_products", {"limit": 3}, intent="product_recommend", context=context
))
lines = ["投顾全景复盘(仅分析,不构成交易指令):", "目标约束下的动态配置:"]
lines.extend(
f"- {item['label']}:{item['target_pct']}%"
for item in cast(list[dict[str, Any]], allocation["allocation"])
)
optimization = allocation.get("optimization")
if isinstance(optimization, dict):
dynamic = "已启用" if optimization.get("dynamic") else "未启用"
coverage = optimization.get("metric_coverage_pct")
lines.append(f"- 动态优化:{dynamic};指标覆盖率 {coverage}%")
if portfolio.get("status") == "ready":
summary = cast(dict[str, Any], portfolio["summary"])
lines.append(
f"持仓概览:估值总市值 {summary['total_market_value']},"
f"产品集中度 HHI {summary['product_hhi']}。"
)
warnings = portfolio.get("warnings")
if isinstance(warnings, list) and warnings:
lines.append("持仓风险提示:")
lines.extend(f"- {item['message']}" for item in warnings if isinstance(item, dict))
else:
lines.append("持仓概览:当前没有可用于复盘的完整持仓估值。")
if rebalance.get("status") == "ready":
gaps = rebalance.get("allocation_gaps")
if isinstance(gaps, list):
lines.append("配置偏离:")
lines.extend(
f"- {item['asset_class']}:当前 {item['current_pct']}%,"
f"目标 {item['target_pct']}%,差异 {item['delta_pct']}%"
for item in gaps if isinstance(item, dict)
)
if recommendation.get("status") == "ready":
products = recommendation.get("products")
if isinstance(products, list):
lines.append("适当性过滤后的候选:")
lines.extend(
f"- {item['product_name']}({item['product_code']}):{item['reason']}"
for item in products if isinstance(item, dict)
)
lines.append("以上内容不生成买卖清单、委托、收益承诺或交易指令。")
lines.append(self._DISCLAIMER)
return CoreResult(text="\n".join(lines))
async def _asset_allocation(self, context: RequestContext) -> CoreResult:
required = {
"asset-allocation:generate:self",
"customer-profile:read:self",
"investment-goal:read:self",
}
if not required.issubset(context.permissions):
return CoreResult(
text=("生成资产配置前需要完成开户风险测评、确认投资目标,并具备相应的只读分析权限。"
"该流程不会执行交易。\n\n" + self._DISCLAIMER)
)
result = cast(dict[str, Any], await self.call_tool(
"generate_asset_allocation", {}, intent="asset_allocation", context=context
))
status = result.get("status")
if status == "profile_required":
return CoreResult(
text="请先完成开户风险测评后再生成资产配置建议。\n\n" + self._DISCLAIMER
)
if status == "investment_goal_required":
return CoreResult(
text=("请先采集收益目标、最大回撤、流动性和投资期限,再生成资产配置建议。\n\n"
+ self._DISCLAIMER)
)
if status == "investment_goal_confirmation_required":
return CoreResult(
text="投资目标尚待您确认,确认后才能用于生成资产配置建议。\n\n" + self._DISCLAIMER
)
if status != "ready":
return CoreResult(text="暂时无法生成资产配置建议,请稍后重试。\n\n" + self._DISCLAIMER)
allocation = cast(list[dict[str, Any]], result["allocation"])
lines = ["建议的场内基金模拟配置比例:"]
lines.extend(f"- {item['label']}:{item['target_pct']}%" for item in allocation)
lines.append("该比例基于已确认目标生成,仅用于分析参考,不会创建委托或执行交易。")
lines.append(self._DISCLAIMER)
return CoreResult(text="\n".join(lines))
async def _portfolio_analysis(self, message: str, context: RequestContext) -> CoreResult:
del message
required = {"portfolio-analysis:read:self", "customer-profile:read:self"}
if not required.issubset(context.permissions):
return CoreResult(
text=("进行持仓集中度分析前需要完成开户风险测评,并具备相应的只读分析权限。"
"该流程不会执行交易。\n\n" + self._DISCLAIMER)
)
result = cast(dict[str, Any], await self.call_tool(
"analyze_portfolio", {}, intent="portfolio_analysis", context=context
))
status = result.get("status")
if status == "profile_required":
return CoreResult(text="请先完成开户风险测评后再进行持仓分析。\n\n" + self._DISCLAIMER)
if status == "no_positions":
return CoreResult(text="当前没有可分析的场内基金持仓。\n\n" + self._DISCLAIMER)
if status == "valuation_required":
return CoreResult(
text=("当前持仓缺少可用市值,暂不能计算集中度,请等待行情数据更新。\n\n"
+ self._DISCLAIMER)
)
if status != "ready":
return CoreResult(text="暂时无法完成持仓分析,请稍后重试。\n\n" + self._DISCLAIMER)
summary = cast(dict[str, Any], result["summary"])
positions = cast(list[dict[str, Any]], result["top_positions"])
industries = cast(list[dict[str, Any]], result["top_industries"])
warnings = cast(list[dict[str, Any]], result["warnings"])
lines = [
f"已估值持仓总市值:{summary['total_market_value']},"
f"产品集中度 HHI:{summary['product_hhi']}。",
"主要持仓:",
]
lines.extend(
f"- {item['product_name']}({item['product_code']}):{item['share_pct']}%"
for item in positions
)
coverage = Decimal(str(summary["industry_coverage_pct"]))
if coverage >= Decimal("80") and industries:
lines.append("主要行业穿透:")
lines.extend(f"- {item['industry_name']}:{item['share_pct']}%" for item in industries)
else:
lines.append("行业穿透参考数据覆盖不足,暂不输出行业集中度结论。")
if warnings:
lines.append("风险提示:")
lines.extend(f"- {item['message']}" for item in warnings)
graph_context = result.get("graph_context")
if isinstance(graph_context, dict):
if graph_context.get("degraded"):
lines.append("图谱关系增强信息暂不可用;以上数值分析仍基于持仓和产品权威数据。")
else:
overlaps = graph_context.get("overlaps")
if isinstance(overlaps, list):
evidence = [
f"{item['industry_name']}:{item['product_count']}只产品"
for item in overlaps
if isinstance(item, dict)
and isinstance(item.get("industry_name"), str)
and isinstance(item.get("product_count"), int)
]
if evidence:
lines.append(
"图谱关系提示:多个持仓产品关联同一行业("
+ "、".join(evidence)
+ ")。该提示仅用于辅助识别关系重叠,不改变上述浓度计算。"
)
lines.append("以上为持仓结构分析,不构成买卖、调仓或交易指令。")
lines.append(self._DISCLAIMER)
return CoreResult(text="\n".join(lines))
async def _portfolio_rebalance(self, context: RequestContext) -> CoreResult:
required = {
"portfolio-analysis:read:self",
"asset-allocation:generate:self",
"customer-profile:read:self",
"investment-goal:read:self",
}
if not required.issubset(context.permissions):
return CoreResult(
text="请先完成风险测评、投资目标确认并获得只读分析权限。\n\n" + self._DISCLAIMER
)
result = cast(dict[str, Any], await self.call_tool(
"simulate_portfolio_rebalance", {}, intent="asset_allocation", context=context
))
if result.get("status") == "classification_required":
return CoreResult(
text=(
"持仓产品的资产类别参考数据覆盖不足,暂不输出调仓比例差。\n\n"
+ self._DISCLAIMER
)
)
if result.get("status") != "ready":
return CoreResult(text="暂时无法完成模拟调仓分析,请稍后重试。\n\n" + self._DISCLAIMER)
gaps = result.get("allocation_gaps")
if not isinstance(gaps, list):
return CoreResult(text="暂时无法完成模拟调仓分析,请稍后重试。\n\n" + self._DISCLAIMER)
lines = ["当前持仓与目标配置的模拟比例差:"]
for item in gaps:
if not isinstance(item, dict):
continue
lines.append(
f"- {item.get('asset_class')}:当前 {item.get('current_pct')}%,"
f"目标 {item.get('target_pct')}%,差额 {item.get('delta_pct')}%"
)
lines.append("以上仅为配置偏离分析,不生成买卖清单、委托或交易指令。")
lines.append(self._DISCLAIMER)
return CoreResult(text="\n".join(lines))
async def _product_recommendation(self, context: RequestContext) -> CoreResult:
required = {
"product-recommendation:read:self",
"customer-profile:read:self",
"investment-goal:read:self",
"suitability:read",
}
if not required.issubset(context.permissions):
return CoreResult(
text=("生成产品推荐前需要完成开户风险测评、确认投资目标,并具备相应的只读分析权限。"
"该流程不会执行交易。\n\n" + self._DISCLAIMER)
)
result = cast(dict[str, Any], await self.call_tool(
"recommend_products", {"limit": 3}, intent="product_recommend", context=context
))
status = result.get("status")
if status == "profile_required":
return CoreResult(text="请先完成开户风险测评后再获取产品推荐。\n\n" + self._DISCLAIMER)
if status == "investment_goal_required":
return CoreResult(
text="请先采集收益目标、最大回撤、流动性和投资期限,再获取产品推荐。\n\n"
+ self._DISCLAIMER
)
if status == "investment_goal_confirmation_required":
return CoreResult(
text="投资目标尚待您确认,确认后才能用于产品推荐分析。\n\n" + self._DISCLAIMER
)
if status == "no_suitable_products":
return CoreResult(
text="当前没有同时满足在售状态和适当性要求的场内基金候选。\n\n" + self._DISCLAIMER
)
if status != "ready":
return CoreResult(text="暂时无法生成产品推荐,请稍后重试。\n\n" + self._DISCLAIMER)
products = cast(list[dict[str, Any]], result["products"])
lines = ["场内基金模拟分析候选:"]
for product in products:
lines.append(
f"- {product['product_name']}({product['product_code']}):{product['reason']}"
)
if result.get("graph_degraded"):
lines.append("图谱关联信息暂不可用,本次排序未使用该项增强信号。")
lines.append("以上仅供分析参考,不构成产品推介、收益承诺或交易指令。")
lines.append(self._DISCLAIMER)
return CoreResult(text="\n".join(lines))
def _comparison_unavailable(self) -> CoreResult:
return CoreResult(
text=("已识别为对比分析需求。多实体图谱查询和授权范围校验服务尚未接入,"
"当前不能生成跨客户或跨产品的对比报告。\n\n" + self._DISCLAIMER)
)
async def _investment_goal(self, context: RequestContext) -> CoreResult:
collection_prompt = (
"请先确认四项投资目标:期望年化收益区间、可承受的最大回撤、资金可使用时间,"
"以及投资期限(月)。收益目标将以业绩比较基准口径记录,不作为收益承诺。"
)
if "investment-goal:read:self" not in context.permissions:
return CoreResult(text=collection_prompt + "\n\n" + self._DISCLAIMER)
goal = cast(dict[str, Any] | None, await self.call_tool(
"query_investment_goal", {}, intent="investment_goal", context=context
))
if goal is None:
return CoreResult(text=collection_prompt + "\n\n" + self._DISCLAIMER)
return CoreResult(
text=(
"已记录您的投资目标:预期年化收益区间 "
f"{goal['annualized_return_lower_pct']}%-{goal['annualized_return_upper_pct']}%,"
f"最大回撤 {goal['max_drawdown_pct']}%,投资期限 "
f"{goal['investment_horizon_months']} 个月。"
"投资目标书仍须经过持证投顾审核,当前不构成投资建议或交易指令。\n\n"
+ self._DISCLAIMER
)
)
async def _quote_funds(self, codes: tuple[str, ...], context: RequestContext) -> CoreResult:
quotes = cast(list[dict[str, Any]], await self.call_tool(
"query_fund_quote",
{"fund_codes": list(codes), "limit": len(codes)},
intent="fund_quote",
context=context,
))
if not quotes:
return CoreResult(text="未获取到可用基金行情,请稍后重试。\n\n" + self._DISCLAIMER)
lines = ["基金行情:"]
for quote in quotes:
name = str(quote.get("fund_name") or "基金")
code = str(quote.get("fund_code") or "")
nav = quote.get("nav")
change = quote.get("daily_change")
nav_date = quote.get("nav_date") or "未知日期"
source = quote.get("quote_source") or "degraded"
nav_text = nav if nav is not None else "暂无"
change_text = change if change is not None else "暂无"
line = f"- {name}({code}):净值 {nav_text},日涨跌 {change_text},日期 {nav_date}。"
if source == "degraded" or quote.get("degraded"):
line += " 当前数据已降级,仅供参考。"
lines.append(line)
lines.extend(("行情用于分析,不代表委托、成交或收益保证。", self._DISCLAIMER))
return CoreResult(text="\n".join(lines))
async def _query_financial_data(
self, message: str, context: RequestContext
) -> CoreResult:
if "financial:nl2sql:read" not in context.permissions:
return CoreResult(
text=("当前身份没有查询金融明细数据的权限,无法展示持仓、交易或账户信息。\n\n"
+ self._DISCLAIMER)
)
result = cast(dict[str, Any], await self.call_tool(
"query_financial_data",
{"question": message, "dry_run": False, "limit": 50},
intent="financial_query",
context=context,
))
status = str(result.get("status", "unknown"))
message_text = str(result.get("message", "金融数据查询已完成。"))
if status == "need_confirmation":
return CoreResult(text=message_text + "\n\n" + self._DISCLAIMER)
if status != "success":
return CoreResult(text=f"未能完成金融数据查询:{message_text}\n\n{self._DISCLAIMER}")
data = result.get("data")
if isinstance(data, dict):
count = int(data.get("total", 0))
elif isinstance(data, list):
count = len(data)
else:
count = 0
return CoreResult(
text=(f"{message_text} 共返回 {count} 条记录。结果仅限授权数据范围内的分析,"
f"不构成交易指令。\n\n{self._DISCLAIMER}")
)
async def _check_suitability(
self, risks: tuple[int, int], context: RequestContext
) -> CoreResult:
customer_risk, product_risk = risks
result = cast(dict[str, Any], await self.call_tool(
"check_suitability",
{
"customer_risk_level": customer_risk,
"product_risk_level": product_risk,
"product_requires_disclosure": True,
},
intent="suitability",
context=context,
))
if not result.get("allowed"):
return CoreResult(
text=("适当性初步校验未通过,不能据此推荐该风险等级产品。原因:"
f"{result.get('reason_code', 'UNKNOWN')}。\n\n{self._DISCLAIMER}")
)
return CoreResult(
text=("适当性初步校验通过,但仍需核对风险测评有效期、产品风险评级和风险揭示确认。"
"该结论不构成产品推荐或交易确认。\n\n" + self._DISCLAIMER)
)
@staticmethod
def _risk_levels(message: str) -> tuple[int, int] | None:
customer = re.search(r"(?:客户|投资者)?\s*[Cc]\s*([1-5])", message)
product = re.search(r"(?:产品|基金)?\s*[Rr]\s*([1-5])", message)
if customer is None or product is None:
return None
return int(customer.group(1)), int(product.group(1))